">
');
","noIndex":false,"favicon":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","keywords":"","author":"timothyredelfotography.com","language":"en","locale":"en_US","siteName":"timothyredelfotography.com"};
console.log('Client Initialized site metadata:', window.siteMetadata);
console.log('Client Google Analytics from metadataToUse:', ' ');
console.log('Client Google Analytics in window.siteMetadata:', window.siteMetadata.googleAnalytics);
console.log('Client Content Language:', window.siteMetadata.contentLanguage);
// State Management
let galleries = [{"id":1748576989763,"title":"GETTING STARTED","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748576989763","visible":false,"parentId":null,"siteId":"tma73fpp","slug":"getting-started","url":"/getting-started","pageElements":[{"id":1748576998046,"type":"metadata","visible":true,"position":1,"metaTitle":"","metaDescription":"","metaKeywords":""},{"id":1748577017097,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"
GETTING STARTED
","textWidth":100},{"id":1748577013784,"type":"embedded-content","title":"New Element","visible":true,"position":2,"contentUrl":"https://player.vimeo.com/video/1088272581?h=7405cc3400&badge=0&autopause=0&player_id=0&app_id=58479","containerWidth":82,"containerHeight":67,"useAutoHeight":false}]},{"id":1748577062628,"title":"Spacer","parentId":null,"visible":true,"isSpacer":true},{"id":1748282240591,"title":"Home","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748282240591","visible":false,"parentId":null,"siteId":"tma73fpp","slug":"home","url":"/home","pageElements":[{"id":1748282250301,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Timothy Redel Photography","metaDescription":"Timothy Redel is an American photographer based in Hong Kong.","metaKeywords":""},{"id":1753734466803,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"
","textWidth":70}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicCategoryId":2922,"classicGuid":"4bd5ebf3a78e0","importSource":"classic-page","isHomePage":false},{"id":1748281880598,"title":"LinkedIn","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748281880598","visible":true,"parentId":1748285961523,"siteId":"4bd5ebf3a78e0","slug":"linkedin","url":"/linkedin","pageElements":[{"id":1748281881598,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Professional business network with 1000+ contacts.","metaDescription":"Professional business network with 1000 + contacts.","metaKeywords":""},{"id":1748281882598,"type":"text","title":"Text Block Spacer","visible":true,"position":1,"textContent":"
","textWidth":70}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicCategoryId":31714,"classicGuid":"4bd5ebf3a78e0","importSource":"classic-page","isHomePage":false},{"id":1748281880584,"title":"Memberships","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748281880584","visible":true,"parentId":1748285961523,"siteId":"4bd5ebf3a78e0","slug":"memberships","url":"/memberships","pageElements":[{"id":1748281881584,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"APA - American Photographic Artists, EP - Editorial Photographers","metaDescription":"APA - American Photographic Artists, EP - Editorial Photographers","metaKeywords":""},{"id":1748281882584,"type":"text","title":"Text Block Spacer","visible":true,"position":1,"textContent":"
American Photographic Artists, the leading trade association representing the interests of advertising photographers, works to improve the environment for success in the industry and champions the rights of photographers worldwide.
Editorial Photographers is an organization of top magazine and news photographers from around the world dedicated to improving business practices and contracts.
","textWidth":70}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicCategoryId":7611,"classicGuid":"4bd5ebf3a78e0","importSource":"classic-page","isHomePage":false},{"id":1748281880605,"title":"About ","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748281880605","visible":true,"parentId":null,"siteId":"4bd5ebf3a78e0","slug":"about","url":"/about","pageElements":[{"id":1748281881605,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Award-winning American photographer from NYC who lives in Hong Kong. ","metaDescription":"Award-winning American photographer from NYC who lives in Hong Kong. ","metaKeywords":""},{"id":1748281882605,"type":"text","title":"Text Block Spacer","visible":true,"position":1,"textContent":"
Timothy Redel is an American photographer from NYC who lives in Hong Kong.
Redel's portraits of artists, celebrities, business leaders and politicians have graced the covers and inside hundreds of the worlds top magazines, including GQ, Rolling Stone, Cosmopolitan, Glamour, Forbes, Business Week and TIME Magazine. And created portraits for advertising, multi international corporate annual reports, book publishing and private commissions.
Redel's work has won many awards, most notably from the Communication Arts Photography Annual. And his work has taken him to almost every state in the United States and many countries around the world.
Redel's passion for photography began during a dinner party his parents were giving. With the music of Sinatra and laughter in the air, a family friend who collected cars, cameras, guns and women, arrived with a beautiful blonde and a large silver case and casually emptied its contents onto his bed. Cameras, lenses and objects of all shapes and sizes lay everywhere to be played with. He was a kid in a candy store and awoke the next morning still holding a camera. He was seven years old.
During his early teens he enrolled in photography classes at school and quickly discovered a natural talent that earned him awards in photography contests. And it led to graduating High School with honors in Graphic Arts/Overall Performance. Living in a small town in Pennsylvania his exposure to commercial photography was through magazines, especially Harpers Bazaar, Vogue, Interview and GQ. It's on those pages that Redel discovered Richard Avedon, Hiro, Irving Penn and Bruce Weber’s work. Inspired, he applied and was accepted to the School of Visual Arts in New York City. And during his free time he worked as an assistant photographer to the same photographers whose work he admired. Redel quickly discovered that school wasn't any match for the real world practical experience he was gaining. And after his first year set off in a new and exciting direction and worked for the next four years for some of the most respected and famous photographers in the world.
Redel’s passion keeps him in demand and in his free time working on personal projects. Aside from his career, he's a professionally trained and skilled open wheel racecar driver. And a passionate road racing cyclist.
","textWidth":89}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicCategoryId":33454,"classicGuid":"4bd5ebf3a78e0","importSource":"classic-page","isHomePage":false},{"id":1748281880556,"title":"Contact","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1748281880556","visible":true,"parentId":null,"siteId":"4bd5ebf3a78e0","slug":"contact","url":"/contact","pageElements":[{"id":1748281881556,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Tel + 852 91717150","metaDescription":"Tel + 852 91717150","metaKeywords":"timothy redel, timothy redel contact, timothy redel fotography, hong kong fotographer,"},{"id":1748281882556,"type":"text","title":"Text Block Spacer","visible":true,"position":1,"textContent":"
","textWidth":82}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicCategoryId":2634,"classicGuid":"4bd5ebf3a78e0","importSource":"classic-page","isHomePage":false}];
let activeGalleryId = null;
let sortableInstance = null;
let isEditing = false;
let initialPageInfo = null;
// Store the current menu layout type
let currentMenuLayout = null;
document.addEventListener('DOMContentLoaded', () => {
if (window.hydraInitialized) {
console.log("Hydra already initialized, skipping");
return;
}
window.hydraInitialized = true;
document.body.classList.add('hydra-initialized');
window.Parameters = window.Parameters || {};
if (localStorage.getItem('hydra_is_admin') === 'true') {
document.body.classList.add('hydra-admin', 'hydra-authenticated');
isAdmin = true;
isAuthenticated = true;
console.log('Restored admin status from localStorage');
// CRITICAL FIX: Clear all caches except auth tokens for logged-in admins
// This ensures admins always see fresh data when opening new tabs
try {
console.log('Admin detected - clearing all caches except auth tokens...');
// Save auth tokens first
const savedAuthToken = localStorage.getItem('hydra_auth_token');
const savedAuthEmail = localStorage.getItem('hydra_auth_email');
const savedIsAdmin = localStorage.getItem('hydra_is_admin');
const savedDidToken = localStorage.getItem('hydra_did_token');
// Clear ALL localStorage
localStorage.clear();
// Restore auth tokens
if (savedAuthToken) localStorage.setItem('hydra_auth_token', savedAuthToken);
if (savedAuthEmail) localStorage.setItem('hydra_auth_email', savedAuthEmail);
if (savedIsAdmin) localStorage.setItem('hydra_is_admin', savedIsAdmin);
if (savedDidToken) localStorage.setItem('hydra_did_token', savedDidToken);
// Clear ALL sessionStorage (auth tokens are not stored here)
sessionStorage.clear();
console.log('Cleared all caches except auth tokens for admin user');
} catch (cacheError) {
console.warn('Error clearing caches on admin page load:', cacheError);
// Don't fail page load if cache clearing fails
}
}
function ensureSidebarElementsAndRender() {
console.log('Ensuring sidebar elements are available before rendering...');
// Check if SidebarManager exists and has elements
if (window.SidebarManager && (!window.SidebarManager.elements || window.SidebarManager.elements.length === 0)) {
// Try to initialize from siteConfig if available
if (typeof siteConfig !== 'undefined' && siteConfig.sitebarElements && siteConfig.sidebarElements.length > 0) {
console.log('Initializing SidebarManager elements from siteConfig');
window.SidebarManager.elements = siteConfig.sidebarElements;
}
}
// Determine layout and render
let currentMenuLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentMenuLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
if (currentMenuLayout === 'horizontal') {
console.log('Rendering horizontal menu with ensured sidebar elements');
renderHorizontalMenu();
} else {
renderGalleries();
}
}
setTimeout(() => {
console.log("Initializing galleries:", galleries.length);
setTimeout(alignFolderStates, 100);
// CHANGED: Use the new function instead of direct rendering
setTimeout(ensureSidebarElementsAndRender, 100); // Small delay to ensure sidebar init
initMobileMenu();
// Handle direct URL navigation
if (initialPageInfo) {
console.log("Loading initial page from direct URL:", initialPageInfo);
// Find the gallery by ID
const gallery = galleries.find(g => g.id === initialPageInfo.id);
if (gallery) {
// Set active gallery ID
activeGalleryId = gallery.id;
if (initialPageInfo.type === 'page') {
console.log("Loading page directly:", gallery.title);
loadPage(gallery.id);
} else {
console.log("Loading gallery directly:", gallery.title);
loadGallery(gallery.id);
}
}
} else if (window.location.pathname === '/') {
// No specific path, try to load home page
console.log("No specific path, checking for home page");
if (!loadHomePage() && galleries.length > 0) {
// Default to first gallery if no home page is set
loadGallery(galleries[0].id);
}
} else if (galleries.length > 0) {
// Path exists but no matching initialPageInfo, try to handle URL path
handleURLNavigation();
}
// Log what was rendered
console.log("Menu content:", document.getElementById('galleryTree')?.innerHTML || "Gallery tree not found");
// Start with edit controls properly set
toggleEditControlsVisibility(isAdmin && document.body.classList.contains('edit-mode-active'));
// Explicitly hide the import classic form too
const importForm = document.getElementById('importClassicForm');
if (importForm) {
importForm.style.display = 'none';
importForm.classList.remove('visible');
}
// Try to restore token from localStorage
if (!didToken) {
didToken = localStorage.getItem('hydra_auth_token');
if (didToken) {
console.log('Restored auth token from localStorage');
}
}
// Initialize Magic and check authentication
// Initialize sidebar elements if available
if (window.SidebarManager) {
// Pass the initial elements from server config if available
if (true) {
window.SidebarManager.elements = [{"id":1743560579307,"type":"menu","title":"Menu","visible":true,"position":2},{"id":1743560590730,"type":"image","title":"Image","visible":true,"position":0,"imageData":"https://storage.neonsky.app/tma73fpp/1748281868362_Timothy_Redel.png","imageWidth":"100%","imageAlignment":"center"},{"id":1743595677459,"type":"social","title":"Social","visible":true,"position":4,"socialIconSize":16,"socialIcons":[{"type":"instagram","url":"https://instagram.com/edmonds.b/"}],"socialAlignment":"left"},{"id":1743597472848,"type":"image","title":"Image","visible":true,"position":1,"imageData":"https://storage.neonsky.app/fi8esmbe/1743597485060_spacer.gif","imageWidth":"10%","imageAlignment":"center"},{"id":1743597627682,"type":"image","title":"Image","visible":true,"position":3,"imageData":"https://storage.neonsky.app/fi8esmbe/1743597639626_spacer.gif","imageWidth":"48%","imageAlignment":"center"}];
}
window.SidebarManager.init();
}
// Initialize metadata from server config
window.siteMetadata = {"title":"Timothy Redel Photography","description":"Timothy Redel is an American photographer based in Hong Kong.","googleAnalytics":" ","noIndex":false,"favicon":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","keywords":"","author":"timothyredelfotography.com","language":"en","locale":"en_US","siteName":"timothyredelfotography.com"};
console.log('Client Initialized site metadata (second init):', window.siteMetadata);
console.log('Client Google Analytics from metadataToUse (second init):', ' ');
console.log('Client Google Analytics in window.siteMetadata (second init):', window.siteMetadata.googleAnalytics);
console.log('Client Content Language (second init):', window.siteMetadata.contentLanguage);
// Update copyright footer after metadata is set (important for main domain)
if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') {
window.SidebarManager.updateMetadataFooter();
}
const submenuCheck = document.getElementById('createSubmenu');
const submenuTitleField = document.getElementById('submenuTitle');
const submenuTitleGroup = submenuTitleField?.parentElement;
if (submenuCheck && submenuTitleGroup) {
submenuCheck.addEventListener('change', function() {
submenuTitleGroup.style.display = this.checked ? 'block' : 'none';
});
}
// Check authentication state after initialization
if (isAuthenticated && isAdmin) {
console.log('Already authenticated and admin, showing edit UI');
forceShowEditUI();
} else if (didToken) {
console.log('Have token, checking admin status');
checkAuth();
}
}, 100); // Small delay to ensure everything is loaded
setTimeout(() => {
if (window.location.pathname === '/' ||
(window.location.hostname === 'preview.neonsky.app' &&
window.location.pathname.split('/').length <= 2)) {
// Root URL or preview URL with only GUID - check for home page
if (!initialPageInfo) { // Only if no specific page was already loaded
loadHomePage();
}
}
}, 500);
});
/**
* Gets the first visible sidebar element (image or text) to be used as a logo or title.
* @returns {object|null} The first suitable sidebar element or null.
*/
function getFirstSidebarElementForHeader() {
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible
if (visibleElements.length > 0) {
const sortedElements = visibleElements.sort((a, b) => a.position - b.position);
for (let el of sortedElements) {
if (el.type === 'image' || el.type === 'text') { // Filter 2: type
return el;
}
}
// If loop finishes, no visible image or text found among visible elements
console.log('[getFirstSidebarElementForHeader] Found visible elements, but none were type image/text.');
return null;
} else {
console.log('[getFirstSidebarElementForHeader] No elements with visible !== false found in SidebarManager.elements.');
return null;
}
}
console.log('[getFirstSidebarElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.');
return null;
}
/**
* Gets the first visible social icon element from sidebar elements.
* @returns {object|null} The first suitable social element or null.
*/
function getFirstSocialElementForHeader() {
if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) {
const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible
if (visibleElements.length > 0) {
const sortedElements = visibleElements.sort((a, b) => a.position - b.position);
for (let el of sortedElements) {
if (el.type === 'social') { // Filter for social type
return el;
}
}
// If loop finishes, no visible social element found
console.log('[getFirstSocialElementForHeader] Found visible elements, but none were type social.');
return null;
} else {
console.log('[getFirstSocialElementForHeader] No elements with visible !== false found in SidebarManager.elements.');
return null;
}
}
console.log('[getFirstSocialElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.');
return null;
}
function ensureCorrectLayoutApplied() {
// Check if MenuStyleCustomizer and its necessary parts exist
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') {
// Get the current layout setting, default to 'sidebar' if not set
const currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
console.log(`Ensuring layout is applied: ${currentLayout}`);
// Apply the layout styles (CSS classes, display properties)
window.MenuStyleCustomizer._applyMenuLayout(currentLayout);
// --- Force Header Structure Rebuild for Horizontal Layout ---
if (currentLayout === 'horizontal') {
const mobileHeader = document.querySelector('.mobile-header');
if (mobileHeader) {
let mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
// Ensure the main content container exists
if (!mobileHeaderContent) {
mobileHeaderContent = document.createElement('div');
mobileHeaderContent.className = 'mobile-header-content';
const hamburgerBtn = mobileHeader.querySelector('.hamburger-btn');
if (hamburgerBtn) {
mobileHeader.insertBefore(mobileHeaderContent, hamburgerBtn);
} else {
mobileHeader.appendChild(mobileHeaderContent);
}
}
// Ensure specific sub-containers exist, creating them if necessary
// This guarantees the structure is present before renderHorizontalMenu fills them.
if (!mobileHeaderContent.querySelector('.horizontal-header-logo')) {
const logoContainer = document.createElement('div');
logoContainer.className = 'horizontal-header-logo';
mobileHeaderContent.insertBefore(logoContainer, mobileHeaderContent.firstChild);
}
if (!mobileHeaderContent.querySelector('.horizontal-menu-container')) {
const menuContainer = document.createElement('div');
menuContainer.className = 'horizontal-menu-container';
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
if (logoContainer) {
logoContainer.insertAdjacentElement('afterend', menuContainer);
} else { // Fallback if logo container wasn't found/created
mobileHeaderContent.insertBefore(menuContainer, mobileHeaderContent.firstChild);
}
}
// Apply flexbox styling to position social container at far right
mobileHeaderContent.style.display = 'flex';
mobileHeaderContent.style.alignItems = 'center';
mobileHeaderContent.style.justifyContent = 'space-between';
mobileHeaderContent.style.width = '100%';
// Create a flex container for logo and menu items
const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container');
if (!logoMenuContainer) {
console.log('[ensureCorrectLayoutApplied] Creating logo-menu wrapper...');
const logoMenuWrapper = document.createElement('div');
logoMenuWrapper.className = 'logo-menu-container';
logoMenuWrapper.style.display = 'flex';
logoMenuWrapper.style.alignItems = 'center';
logoMenuWrapper.style.gap = '20px';
// Move logo and menu into the wrapper
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
console.log('[ensureCorrectLayoutApplied] Found logo container:', !!logoContainer);
console.log('[ensureCorrectLayoutApplied] Found menu container:', !!menuContainer);
if (logoContainer && menuContainer) {
// Insert wrapper before the logo
mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer);
// Move both containers into the wrapper
logoMenuWrapper.appendChild(logoContainer);
logoMenuWrapper.appendChild(menuContainer);
console.log('[ensureCorrectLayoutApplied] Successfully moved logo and menu into wrapper');
} else {
console.warn('[ensureCorrectLayoutApplied] Missing logo or menu container, cannot create wrapper');
}
} else {
console.log('[ensureCorrectLayoutApplied] Logo-menu wrapper already exists');
}
// Add social icons container for horizontal layout (AFTER logo-menu wrapper is created)
if (!mobileHeaderContent.querySelector('.horizontal-social-container')) {
const socialContainer = document.createElement('div');
socialContainer.className = 'horizontal-social-container';
// Insert at the end (far right) of the mobile header content
mobileHeaderContent.appendChild(socialContainer);
}
}
}
// --- End Header Structure Rebuild ---
// Re-render the correct menu structure based on the applied layout
if (currentLayout === 'horizontal') {
// If horizontal layout, render the horizontal menu
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
} else {
console.warn("renderHorizontalMenu function not found.");
}
// After rendering horizontal menu, update mobile header content
// This ensures mobile header shows logo instead of menu items on mobile
setTimeout(() => {
if (typeof updateMobileHeaderContent === 'function') {
updateMobileHeaderContent();
}
}, 100);
} else {
// For sidebar or top layouts, render the standard gallery tree
if (typeof renderGalleries === 'function') {
renderGalleries();
} else {
console.warn("renderGalleries function not found.");
}
// Update mobile header content for other layouts too
setTimeout(() => {
if (typeof updateMobileHeaderContent === 'function') {
updateMobileHeaderContent();
}
}, 100);
}
} else {
// Log a warning if the necessary components aren't available
console.warn("MenuStyleCustomizer or its methods not available to re-apply layout.");
}
}
function applyLayoutStylesOnly() {
// Check if MenuStyleCustomizer and its necessary parts exist
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') {
// Get the current layout setting, default to 'sidebar' if not set
const currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
console.log(`APPLY STYLES: Applying styles for layout: ${currentLayout}`);
// Apply the layout styles (CSS classes, display properties)
window.MenuStyleCustomizer._applyMenuLayout(currentLayout); // This function should ONLY set body classes/styles
} else {
console.warn("APPLY STYLES: MenuStyleCustomizer or its methods not available.");
}
}
// Add this function to ensure sidebar content for mobile
function ensureSidebarContentForMobile() {
const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal');
const isMobileView = window.innerWidth <= 768;
if (isHorizontalLayout && isMobileView) {
console.log('Ensuring sidebar content for mobile horizontal layout');
// Make sure the sidebar tree is populated
const galleryTreeContainer = document.getElementById('galleryTree');
if (galleryTreeContainer && (!galleryTreeContainer.innerHTML || galleryTreeContainer.innerHTML.trim() === '')) {
console.log('Sidebar tree is empty, populating with galleries');
// Render the galleries in the sidebar tree for mobile navigation
if (typeof renderGalleries === 'function') {
renderGalleries();
}
}
// Also ensure sidebar elements are rendered
if (window.SidebarManager && typeof window.SidebarManager.renderElements === 'function') {
console.log('Re-rendering sidebar elements for mobile');
window.SidebarManager.renderElements();
}
}
}
function renderTextLogo(container, htmlContentFromSidebarElement) {
if (!container) {
console.error("[RenderTextLogo] Error: Logo container not found.");
return;
}
// Clear any existing content from the logo container.
container.innerHTML = '';
// Directly inject the HTML content from the sidebar element.
// This preserves the original HTML structure (like
, ) and any inline styles.
// The .horizontal-header-logo CSS should handle alignment (e.g., display: flex; align-items: center;).
container.innerHTML = htmlContentFromSidebarElement || '
Menu
'; // Fallback text wrapped in a div
// Optional: If the root element of htmlContentFromSidebarElement (e.g., an
)
// has default margins that interfere with flex centering, you might need CSS like:
// .horizontal-header-logo > h1 { margin: 0; }
// This would typically go in your menu-styles.css file.
console.log(`[RenderTextLogo] Text logo HTML rendered directly into container.`);
}
function renderHorizontalMenu() {
console.log("[RenderHorizontalMenu] Function CALLED. Current edit mode:", typeof isEditing !== 'undefined' ? isEditing : 'isEditing_undefined');
const mobileHeader = document.querySelector('.mobile-header');
if (!mobileHeader) {
console.error("[RenderHorizontalMenu] CRITICAL: .mobile-header element NOT FOUND.");
return;
}
// Apply flexbox styling to position social container at far right
const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
if (mobileHeaderContent) {
mobileHeaderContent.style.display = 'flex';
mobileHeaderContent.style.alignItems = 'center';
mobileHeaderContent.style.justifyContent = 'space-between';
mobileHeaderContent.style.width = '100%';
// Create a flex container for logo and menu items
const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container');
if (!logoMenuContainer) {
console.log('[RenderHorizontalMenu] Creating logo-menu wrapper...');
const logoMenuWrapper = document.createElement('div');
logoMenuWrapper.className = 'logo-menu-container';
logoMenuWrapper.style.display = 'flex';
logoMenuWrapper.style.alignItems = 'center';
logoMenuWrapper.style.gap = '20px';
// Move logo and menu into the wrapper
const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo');
const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
console.log('[RenderHorizontalMenu] Found logo container:', !!logoContainer);
console.log('[RenderHorizontalMenu] Found menu container:', !!menuContainer);
if (logoContainer && menuContainer) {
// Insert wrapper before the logo
mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer);
// Move both containers into the wrapper
logoMenuWrapper.appendChild(logoContainer);
logoMenuWrapper.appendChild(menuContainer);
console.log('[RenderHorizontalMenu] Successfully moved logo and menu into wrapper');
} else {
console.warn('[RenderHorizontalMenu] Missing logo or menu container, cannot create wrapper');
}
} else {
console.log('[RenderHorizontalMenu] Logo-menu wrapper already exists');
}
}
const logoContainer = mobileHeader.querySelector('.horizontal-header-logo');
if (!logoContainer) {
console.error("[RenderHorizontalMenu] CRITICAL: .horizontal-header-logo container NOT FOUND within .mobile-header.");
return;
}
console.log("[RenderHorizontalMenu] Found .horizontal-header-logo container");
logoContainer.innerHTML = ''; // Clear existing logo content first
// FIXED: Check if we're on mobile - if so, don't render the horizontal logo
const isMobileView = window.innerWidth <= 768; // Adjust breakpoint as needed
if (isMobileView) {
console.log("[RenderHorizontalMenu] Mobile view detected - skipping horizontal logo rendering");
// On mobile, the logo will be handled by updateMobileHeaderContent()
} else {
console.log("[RenderHorizontalMenu] Desktop view - rendering horizontal logo");
// Get the logo element data
let logoElementData = null;
try {
logoElementData = getFirstSidebarElementForHeader();
console.log('[RenderHorizontalMenu] getFirstSidebarElementForHeader() returned:', JSON.stringify(logoElementData));
} catch (error) {
console.error('[RenderHorizontalMenu] Error getting sidebar element:', error);
logoElementData = getFirstSidebarElementFallback();
}
if (logoElementData) {
if (logoElementData.type === 'image' && (logoElementData.imageData || logoElementData.imageUrl)) {
console.log('[RenderHorizontalMenu] Attempting to render IMAGE logo with src:', logoElementData.imageData || logoElementData.imageUrl);
const img = document.createElement('img');
img.src = logoElementData.imageUrl || logoElementData.imageData;
img.alt = logoElementData.title || 'Site Logo';
// Apply comprehensive styling to ensure visibility
img.style.display = 'block';
// --- FIX ---
// The line below was removed. It was applying a fixed max-height of 50px,
// overriding your CSS. Removing it allows your stylesheet to take control.
// img.style.maxHeight = '50px';
img.style.width = 'auto';
img.style.objectFit = 'contain';
img.style.minWidth = '1px';
img.style.minHeight = '1px';
img.style.visibility = 'visible';
img.style.opacity = '1';
// Enhanced event listeners for debugging
img.onload = () => {
console.log('[RenderHorizontalMenu] Image successfully LOADED');
};
img.onerror = (error) => {
console.error('[RenderHorizontalMenu] Image FAILED to load:', error);
renderTextLogo(logoContainer, logoElementData.title || 'Logo');
};
try {
// Handle linking (similar to sidebar-manager.js)
// Only add links in view mode, not edit mode
const isEditing = window.SidebarManager?.isEditing ||
document.body.classList.contains('edit-mode-active') ||
(typeof window.isInEditMode === 'function' && window.isInEditMode());
if (logoElementData.linkType && logoElementData.linkType !== 'none' && !isEditing) {
const link = document.createElement('a');
link.target = logoElementData.linkTarget || '_self';
let finalLinkHref = '#';
let isValidLink = false;
if (logoElementData.linkType === 'external' && logoElementData.linkUrl) {
finalLinkHref = logoElementData.linkUrl;
if (link.target === '_blank') {
link.rel = 'noopener noreferrer';
}
isValidLink = true;
} else if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) {
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const targetPage = allGalleries.find(g => g.id === parseInt(logoElementData.linkPageId));
if (targetPage) {
// Handle preview mode URLs
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
if (isPreviewMode) {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const siteGuid = pathParts[0];
if (siteGuid && targetPage.slug) {
finalLinkHref = '/' + siteGuid + '/' + targetPage.slug;
isValidLink = true;
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
}
} else {
if (targetPage.slug) {
finalLinkHref = '/' + targetPage.slug;
isValidLink = true;
} else if (targetPage.url && targetPage.url.startsWith('/')) {
finalLinkHref = targetPage.url;
isValidLink = true;
}
}
}
}
if (isValidLink) {
link.href = finalLinkHref;
// For internal links, add click handler to use site navigation
if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) {
link.addEventListener('click', (e) => {
e.preventDefault();
const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []);
const targetPage = allGalleries.find(g => g.id === parseInt(logoElementData.linkPageId));
if (targetPage) {
if (targetPage.isPage && typeof window.loadPage === 'function') {
window.loadPage(targetPage.id);
} else if (typeof window.loadGallery === 'function') {
window.loadGallery(targetPage.id);
} else {
// Fallback to standard navigation
window.location.href = finalLinkHref;
}
} else {
// Fallback to standard navigation if page not found
window.location.href = finalLinkHref;
}
});
}
link.appendChild(img);
logoContainer.appendChild(link);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended linked image to logoContainer.');
} else {
logoContainer.appendChild(img);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.');
}
} else {
logoContainer.appendChild(img);
console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.');
}
} catch (e) {
console.error('[RenderHorizontalMenu] Error during logoContainer.appendChild(img):', e);
renderTextLogo(logoContainer, logoElementData.title || 'Logo');
}
} else if (logoElementData.type === 'text' && logoElementData.textContent) {
console.log('[RenderHorizontalMenu] Attempting to render TEXT logo:', logoElementData.textContent);
renderTextLogo(logoContainer, logoElementData.textContent);
} else {
console.log('[RenderHorizontalMenu] Using title as fallback logo');
renderTextLogo(logoContainer, logoElementData.title || 'Menu');
}
} else {
console.log("[RenderHorizontalMenu] No logoElementData available. Using default text.");
renderTextLogo(logoContainer, 'Menu');
}
}
// Render menu items (only on desktop, not on mobile)
const menuContainer = mobileHeader.querySelector('.horizontal-menu-container');
if (!menuContainer) {
console.error("[RenderHorizontalMenu] .horizontal-menu-container NOT FOUND within .mobile-header.");
} else {
menuContainer.innerHTML = '';
// Only populate menu items on desktop, not on mobile
if (!isMobileView) {
let galleryTree = [];
if (typeof createGalleryTree === 'function' && typeof galleries !== 'undefined') {
galleryTree = createGalleryTree(galleries);
} else {
console.warn("[RenderHorizontalMenu] createGalleryTree function or galleries array is undefined.");
}
galleryTree.forEach(item => {
const menuItemElement = createHorizontalMenuItem(item);
if (menuItemElement) {
menuContainer.appendChild(menuItemElement);
}
});
} else {
console.log("[RenderHorizontalMenu] Mobile view - skipping horizontal menu item population");
}
}
// Render social icons (only on desktop, not on mobile)
let socialContainer = mobileHeader.querySelector('.horizontal-social-container');
if (!socialContainer) {
// Fallback: create the social container if it doesn't exist
console.log("[RenderHorizontalMenu] .horizontal-social-container not found, creating it...");
const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content');
if (mobileHeaderContent) {
socialContainer = document.createElement('div');
socialContainer.className = 'horizontal-social-container';
mobileHeaderContent.appendChild(socialContainer);
console.log("[RenderHorizontalMenu] Created .horizontal-social-container");
} else {
console.error("[RenderHorizontalMenu] .mobile-header-content not found, cannot create social container");
}
}
if (socialContainer) {
socialContainer.innerHTML = '';
// Only populate social icons on desktop, not on mobile
if (!isMobileView) {
const socialElement = getFirstSocialElementForHeader();
if (socialElement && socialElement.type === 'social') {
console.log('[RenderHorizontalMenu] Found social element, rendering social icons');
renderSocialIcons(socialContainer, socialElement);
} else {
console.log('[RenderHorizontalMenu] No social element found or element is not social type');
// Container remains empty - no impact on layout
}
} else {
console.log("[RenderHorizontalMenu] Mobile view - skipping social icons population");
}
}
if (typeof updateActiveStatesHorizontal === 'function') {
updateActiveStatesHorizontal();
}
setTimeout(() => {
ensureSidebarContentForMobile();
}, 100);
console.log("[RenderHorizontalMenu] Function COMPLETED.");
}
/**
* Renders social icons in the horizontal header container.
* @param {HTMLElement} container - The container to render social icons in
* @param {object} socialElement - The social element data from sidebar
*/
function renderSocialIcons(container, socialElement) {
console.log('[RenderSocialIcons] Rendering social icons:', socialElement);
try {
// Safety check: ensure we have a valid social element
if (!socialElement || socialElement.type !== 'social') {
console.log('[RenderSocialIcons] Invalid social element - skipping rendering');
return;
}
// Find the existing sidebar social icons and clone them
const sidebarSocialContainer = document.querySelector('.sidebar-social-container');
if (sidebarSocialContainer) {
const sidebarSocialLinks = sidebarSocialContainer.querySelectorAll('a[href]');
sidebarSocialLinks.forEach(link => {
// Clone the entire link element with all its styling and functionality
const clonedLink = link.cloneNode(true);
// Remove any edit-specific classes or attributes
clonedLink.classList.remove('edit-social-icon', 'delete-social-icon');
// Ensure it opens in new tab
clonedLink.target = '_blank';
clonedLink.rel = 'noopener noreferrer';
// Add horizontal menu specific class
clonedLink.classList.add('horizontal-social-link');
// Apply horizontal menu specific styling - let CSS handle most styling
clonedLink.style.cssText = 'display: flex !important; align-items: center !important; justify-content: center !important; width: 30px !important; height: 30px !important; color: var(--menu-color, #333) !important; text-decoration: none !important; flex-shrink: 0 !important;';
// Style the icon wrapper to match horizontal menu sizing
const iconWrapper = clonedLink.querySelector('.social-icon-wrapper');
if (iconWrapper) {
iconWrapper.style.cssText = 'display: flex !important; align-items: center !important; justify-content: center !important;';
}
// Style the SVG to use proper sizing
const svg = clonedLink.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
svg.setAttribute('width', '20px');
svg.setAttribute('height', '20px');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.style.color = 'inherit';
svg.style.fill = 'currentColor';
svg.style.stroke = 'currentColor';
}
// Remove any click handlers that might interfere
const newLink = clonedLink.cloneNode(true);
container.appendChild(newLink);
console.log('[RenderSocialIcons] Cloned sidebar social link:', newLink.href);
});
} else {
console.log('[RenderSocialIcons] No sidebar social container found to clone from');
}
} catch (error) {
console.error('[RenderSocialIcons] Error rendering social icons:', error);
}
}
/**
* Gets the appropriate icon text or class for a social platform.
* @param {string} platform - The social platform name
* @returns {string} The icon text or class
*/
function getSocialIconText(platform) {
const iconMap = {
'facebook': 'f',
'twitter': 't',
'instagram': 'i',
'linkedin': 'in',
'youtube': 'yt',
'pinterest': 'p',
'tiktok': 'tt',
'snapchat': 'sc',
'whatsapp': 'wa',
'telegram': 'tg',
'discord': 'd',
'github': 'gh',
'email': '@',
'phone': '📞',
'website': '🌐'
};
return iconMap[platform.toLowerCase()] || '•';
}
/**
* Creates a single top-level horizontal menu item and its submenu if it has children.
* (UPDATED to handle async loadGallery and pass clickedItemId to a delayed updateActiveStatesHorizontal)
* @param {object} galleryItem - The gallery item data.
* @returns {HTMLElement | null} The created menu item element, or null if not rendered.
*/
function createHorizontalMenuItem(galleryItem) {
if (galleryItem.visible === false || galleryItem.isSpacer) {
return null;
}
const menuItem = document.createElement('div');
menuItem.className = 'horizontal-menu-item';
menuItem.dataset.id = String(galleryItem.id);
menuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false');
const titleSpan = document.createElement('span');
titleSpan.textContent = galleryItem.title;
menuItem.appendChild(titleSpan);
// Ensure activeGalleryId is treated as a number for comparison during initial render
const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId));
if (galleryItem.id === currentGlobalActiveId) {
menuItem.classList.add('active');
}
let leaveTimer; // For mouseleave timeout to close dropdown
if (galleryItem.children && galleryItem.children.length > 0) {
const visibleChildren = galleryItem.children.filter(child => child.visible !== false && !child.isSpacer);
if (visibleChildren.length > 0) {
menuItem.classList.add('has-children');
const toggleIconContainer = document.createElement('span');
toggleIconContainer.className = 'submenu-toggle-horizontal';
const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgIcon.setAttribute("class", "icon toggle-icon");
svgIcon.setAttribute("viewBox", "0 0 24 24");
svgIcon.setAttribute("fill", "none");
svgIcon.setAttribute("stroke", "currentColor");
svgIcon.setAttribute("stroke-width", "2");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
svgIcon.appendChild(polyline);
toggleIconContainer.appendChild(svgIcon);
menuItem.appendChild(toggleIconContainer);
const subMenu = document.createElement('div');
subMenu.className = 'horizontal-dropdown';
visibleChildren.forEach((childItem) => {
const subMenuItemElement = createHorizontalSubMenuItem(childItem); // Recursive call
if (subMenuItemElement) {
subMenu.appendChild(subMenuItemElement);
}
});
if (subMenu.hasChildNodes()) {
menuItem.appendChild(subMenu);
menuItem.addEventListener('mouseenter', function() {
clearTimeout(leaveTimer);
document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => {
if (openItem !== this) {
openItem.classList.remove('expanded');
const otherIcon = openItem.querySelector('.icon.toggle-icon');
if (otherIcon) otherIcon.classList.remove('rotated');
}
});
this.classList.add('expanded');
const currentIcon = this.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.add('rotated');
});
menuItem.addEventListener('mouseleave', function(event) {
// Check if we're moving to the dropdown or a flyout
const relatedTarget = event.relatedTarget;
const isMovingToDropdown = relatedTarget && (
relatedTarget.closest('.horizontal-dropdown') ||
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item')
);
const isMovingToFlyout = relatedTarget && (
relatedTarget.classList.contains('horizontal-nested-dropdown') ||
relatedTarget.closest('.horizontal-nested-dropdown')
);
if (!isMovingToDropdown && !isMovingToFlyout) {
console.log('Main menu item mouseleave - starting timer');
leaveTimer = setTimeout(() => {
// Check if there's an active flyout - if so, keep parent open
// Use the same multiple detection methods as the submenu handler
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') ||
document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') ||
document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])');
console.log('Main menu timer fired - active flyout:', activeFlyout);
if (!activeFlyout) {
console.log('Main menu closing dropdown - no active flyout');
this.classList.remove('expanded');
const currentIcon = this.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
} else {
console.log('Main menu keeping dropdown open - flyout is active');
}
}, 200);
} else {
console.log('Main menu item mouseleave - not starting timer (moving to dropdown or flyout)');
}
});
subMenu.addEventListener('mouseenter', function() {
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem && parentMenuItem.leaveTimer) {
clearTimeout(parentMenuItem.leaveTimer);
parentMenuItem.leaveTimer = null;
}
});
subMenu.addEventListener('mouseleave', function(event) {
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem) {
// Check if we're moving to another element within the same dropdown
const relatedTarget = event.relatedTarget;
console.log('SubMenu mouseleave - relatedTarget:', relatedTarget);
console.log('SubMenu mouseleave - relatedTarget classes:', relatedTarget ? relatedTarget.className : 'null');
// Check if we're moving to another element within the same dropdown
const currentDropdown = this.closest('.horizontal-dropdown') || this.closest('.horizontal-submenu')?.closest('.horizontal-dropdown');
console.log('Current dropdown found:', !!currentDropdown);
console.log('Current dropdown element:', currentDropdown);
console.log('RelatedTarget:', relatedTarget);
console.log('RelatedTarget closest dropdown:', relatedTarget ? relatedTarget.closest('.horizontal-dropdown') : 'null');
// Check if relatedTarget is within the same dropdown container
const isMovingWithinDropdown = relatedTarget && currentDropdown && (
relatedTarget.closest('.horizontal-dropdown') === currentDropdown ||
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item') ||
currentDropdown.contains(relatedTarget)
);
// Alternative approach: check if relatedTarget is within the same parent menu item
const parentMenuContainer = this.closest('.horizontal-menu-item');
const isMovingWithinParent = relatedTarget && parentMenuContainer && parentMenuContainer.contains(relatedTarget);
console.log('Is moving within parent menu item:', isMovingWithinParent);
console.log('Current dropdown contains relatedTarget:', currentDropdown ? currentDropdown.contains(relatedTarget) : 'no dropdown');
console.log('RelatedTarget closest dropdown === currentDropdown:', relatedTarget && currentDropdown ? relatedTarget.closest('.horizontal-dropdown') === currentDropdown : 'null');
// Also check if mouse is still within the dropdown bounds (in case relatedTarget is null or outside)
// Get the parent menu item's dropdown container
const parentMenuItem = this.closest('.horizontal-menu-item');
console.log('Parent menu item found:', !!parentMenuItem);
const dropdownContainer = parentMenuItem ? parentMenuItem.querySelector('.horizontal-dropdown') : null;
console.log('Dropdown container found:', !!dropdownContainer);
if (dropdownContainer) {
console.log('Dropdown container classes:', dropdownContainer.className);
console.log('Dropdown container display:', window.getComputedStyle(dropdownContainer).display);
console.log('Dropdown container position:', window.getComputedStyle(dropdownContainer).position);
}
// Get bounds from the visible dropdown (not the hidden one)
let dropdownRect = { left: 0, right: 0, top: 0, bottom: 0 };
if (dropdownContainer) {
const computedStyle = window.getComputedStyle(dropdownContainer);
if (computedStyle.display !== 'none') {
dropdownRect = dropdownContainer.getBoundingClientRect();
} else {
// If dropdown is hidden, try to get bounds from the parent menu item
const parentRect = parentMenuItem.getBoundingClientRect();
dropdownRect = parentRect;
}
}
const mouseX = event.clientX;
const mouseY = event.clientY;
const isMouseInDropdownBounds = mouseX >= dropdownRect.left && mouseX <= dropdownRect.right &&
mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom;
console.log('Dropdown bounds:', {
left: dropdownRect.left,
right: dropdownRect.right,
top: dropdownRect.top,
bottom: dropdownRect.bottom,
mouseX,
mouseY,
mouseInBounds: mouseX >= dropdownRect.left && mouseX <= dropdownRect.right && mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom
});
// If there's an active flyout, always keep the parent dropdown open
// Check multiple ways to detect active flyouts
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') ||
document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') ||
document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])');
console.log('Active flyout found:', !!activeFlyout);
if (activeFlyout) {
console.log('Active flyout display style:', activeFlyout.style.display);
console.log('Active flyout computed display:', window.getComputedStyle(activeFlyout).display);
console.log('Active flyout visibility style:', activeFlyout.style.visibility);
}
const hasActiveFlyout = !!activeFlyout;
// If the dropdown is hidden (display: none), we can't get accurate bounds
// So let's use a simpler approach - if we're moving to another submenu item, keep open
const isMovingToSubmenuItem = relatedTarget && (
relatedTarget.classList.contains('horizontal-submenu-item') ||
relatedTarget.closest('.horizontal-submenu-item')
);
console.log('Is moving to submenu item:', isMovingToSubmenuItem);
console.log('RelatedTarget is submenu item:', relatedTarget ? relatedTarget.classList.contains('horizontal-submenu-item') : 'null');
console.log('RelatedTarget has submenu item ancestor:', relatedTarget ? !!relatedTarget.closest('.horizontal-submenu-item') : 'null');
// If we're moving to another submenu item within the same dropdown, always keep open
// OR if there's an active flyout (regardless of where the mouse is), keep open
// Simple approach: if mouse is within dropdown bounds OR there's an active flyout, keep open
const shouldKeepOpen = isMouseInDropdownBounds || hasActiveFlyout || isMovingWithinDropdown || isMovingToSubmenuItem;
console.log('Is moving within dropdown:', isMovingWithinDropdown);
console.log('Is mouse in dropdown bounds:', isMouseInDropdownBounds);
console.log('Has active flyout:', hasActiveFlyout);
console.log('Should keep open:', shouldKeepOpen);
if (!shouldKeepOpen) {
console.log('SubMenu mouseleave - starting timer (moving outside dropdown)');
parentMenuItem.leaveTimer = setTimeout(() => {
// Check if there's an active flyout - if so, keep parent open
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]');
console.log('Timer fired - active flyout:', activeFlyout);
if (!activeFlyout) {
console.log('Closing parent dropdown - no active flyout');
parentMenuItem.classList.remove('expanded');
const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
} else {
console.log('Keeping parent dropdown open - flyout is active');
}
}, 200);
} else {
console.log('SubMenu mouseleave - not starting timer (moving within dropdown)');
}
}
});
} else {
menuItem.classList.remove('has-children');
if (toggleIconContainer.parentNode) {
toggleIconContainer.remove();
}
}
}
}
// Click listener for items that are direct navigation links (not folders with children)
if (!menuItem.classList.contains('has-children')) {
menuItem.addEventListener('click', async (event) => {
event.stopPropagation();
const clickedItemId = galleryItem.id; // Capture the ID of the clicked item
// Check if it's an external URL first
if (galleryItem.isExternal && galleryItem.url) {
window.open(galleryItem.url, '_blank', 'noopener,noreferrer');
return;
}
if (galleryItem.isPage) {
if (typeof loadPage === 'function') {
loadPage(clickedItemId, event); // loadPage is synchronous
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId);
// Use a minimal delay even for sync operations if page rendering has its own async microtasks
setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50);
}
}
} else { // It's a gallery
if (typeof loadGallery === 'function') {
console.log("Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId);
await loadGallery(clickedItemId, event); // Await the asynchronous loadGallery
console.log("Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay.");
if (typeof updateActiveStatesHorizontal === 'function') {
setTimeout(() => {
console.log("[Delayed Update from Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId);
updateActiveStatesHorizontal(clickedItemId); // Pass the captured clickedItemId
}, 100); // Delay to allow gallery system to settle
}
}
}
// Close any other open dropdowns and flyouts
document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => {
// Don't close the parent if a child within its dropdown was clicked (already handled by submenu click)
if (openItem !== menuItem.closest('.horizontal-menu-item.expanded')) {
openItem.classList.remove('expanded');
const icon = openItem.querySelector('.icon.toggle-icon');
if (icon) icon.classList.remove('rotated');
}
});
// Close all flyouts
document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => {
flyout.style.display = 'none';
flyout.style.opacity = '0';
flyout.style.visibility = 'hidden';
});
// Remove expanded class from all submenu items
document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((subItem) => {
subItem.classList.remove('expanded');
});
});
}
return menuItem;
}
/**
* Creates a single horizontal submenu item.
* (UPDATED to handle nested children and async loadGallery)
* @param {object} galleryItem - The gallery item data for the submenu.
* @returns {HTMLElement | null} The created submenu item element, or null if not rendered.
*/
function createHorizontalSubMenuItem(galleryItem) {
if (galleryItem.visible === false || galleryItem.isSpacer) {
return null;
}
const subMenuItem = document.createElement('li');
subMenuItem.className = 'horizontal-submenu-item';
subMenuItem.dataset.id = String(galleryItem.id);
subMenuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false');
// Create the text content
const titleSpan = document.createElement('span');
titleSpan.textContent = galleryItem.title;
subMenuItem.appendChild(titleSpan);
const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId));
if (galleryItem.id === currentGlobalActiveId) {
subMenuItem.classList.add('active');
}
// Check if this submenu item has children (nested sub-sub items)
if (galleryItem.children && galleryItem.children.length > 0) {
const visibleChildren = galleryItem.children.filter(child => child.visible !== false && !child.isSpacer);
if (visibleChildren.length > 0) {
subMenuItem.classList.add('has-children');
// Add arrow indicator for nested items (same as top-level menu)
const arrowSpan = document.createElement('span');
arrowSpan.className = 'submenu-arrow';
const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgIcon.setAttribute("class", "icon toggle-icon");
svgIcon.setAttribute("viewBox", "0 0 24 24");
svgIcon.setAttribute("fill", "none");
svgIcon.setAttribute("stroke", "currentColor");
svgIcon.setAttribute("stroke-width", "2");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
svgIcon.appendChild(polyline);
arrowSpan.appendChild(svgIcon);
subMenuItem.appendChild(arrowSpan);
// Create nested submenu as a true flyout
const nestedSubMenu = document.createElement('div');
nestedSubMenu.className = 'horizontal-nested-dropdown';
// Create a list container for the nested items
const nestedList = document.createElement('ul');
nestedList.className = 'horizontal-nested-list';
visibleChildren.forEach((childItem) => {
const nestedSubMenuItem = createHorizontalSubMenuItem(childItem); // Recursive call
if (nestedSubMenuItem) {
nestedList.appendChild(nestedSubMenuItem);
}
});
nestedSubMenu.appendChild(nestedList);
if (nestedSubMenu.hasChildNodes()) {
// Append to document body for true flyout behavior
document.body.appendChild(nestedSubMenu);
// Store reference to the flyout for cleanup
const flyoutId = 'flyout-' + Date.now() + '-' + Math.random();
subMenuItem.setAttribute('data-flyout-id', flyoutId);
nestedSubMenu.setAttribute('data-flyout-id', flyoutId);
console.log('Created flyout with ID:', flyoutId, 'for item:', galleryItem.title); // Debug
console.log('Flyout element:', nestedSubMenu); // Debug
// Add hover events for nested submenu
let nestedLeaveTimer;
subMenuItem.addEventListener('mouseenter', function() {
if (this.nestedLeaveTimer) {
clearTimeout(this.nestedLeaveTimer);
}
// Keep the parent dropdown open by preventing its mouseleave timer
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem) {
// Clear any existing leave timer on the parent
if (parentMenuItem.leaveTimer) {
clearTimeout(parentMenuItem.leaveTimer);
}
}
// Close other nested dropdowns
document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((openItem) => {
if (openItem !== this) {
openItem.classList.remove('expanded');
// Hide other flyouts
const otherFlyoutId = openItem.getAttribute('data-flyout-id');
if (otherFlyoutId) {
const otherFlyout = document.querySelector('[data-flyout-id="' + otherFlyoutId + '"]');
if (otherFlyout) {
otherFlyout.style.display = 'none';
otherFlyout.style.opacity = '0';
otherFlyout.style.visibility = 'hidden';
}
}
}
});
this.classList.add('expanded');
// Show and position the nested dropdown as a true flyout
const flyoutId = this.getAttribute('data-flyout-id');
console.log('Flyout ID:', flyoutId); // Debug
if (flyoutId) {
// Find the actual flyout container (not the submenu item)
const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + flyoutId + '"]');
console.log('Found nested dropdown:', nestedDropdown); // Debug
if (nestedDropdown) {
const rect = this.getBoundingClientRect();
const parentDropdown = this.closest('.horizontal-dropdown');
const parentRect = parentDropdown ? parentDropdown.getBoundingClientRect() : rect;
console.log('Positioning flyout - rect:', rect, 'parentRect:', parentRect); // Debug
// Position to the right of the parent dropdown (touching)
const leftPos = parentRect.right - 1; // Slight overlap to eliminate gap
const topPos = rect.top;
nestedDropdown.style.left = leftPos + 'px';
nestedDropdown.style.top = topPos + 'px';
nestedDropdown.style.display = 'block';
nestedDropdown.style.opacity = '1';
nestedDropdown.style.visibility = 'visible';
nestedDropdown.style.position = 'fixed'; // Ensure fixed positioning
nestedDropdown.style.zIndex = '1251'; // Ensure high z-index
console.log('Flyout shown - display: block, opacity: 1, visibility: visible');
console.log('Set flyout position:', leftPos, topPos); // Debug
// Check if it would go off-screen and adjust if needed
setTimeout(() => {
const dropdownRect = nestedDropdown.getBoundingClientRect();
if (dropdownRect.right > window.innerWidth) {
// Position to the left of the parent dropdown instead
const newLeftPos = parentRect.left - dropdownRect.width - 5;
nestedDropdown.style.left = newLeftPos + 'px';
console.log('Adjusted flyout position (left side):', newLeftPos); // Debug
}
}, 10);
} else {
console.error('Nested dropdown not found for flyout ID:', flyoutId); // Debug
}
} else {
console.error('No flyout ID found for submenu item'); // Debug
}
});
subMenuItem.addEventListener('mouseleave', function(event) {
// Check if we're moving to the flyout
const flyoutId = this.getAttribute('data-flyout-id');
const relatedTarget = event.relatedTarget;
const isMovingToFlyout = relatedTarget && flyoutId &&
relatedTarget.closest('.horizontal-nested-dropdown[data-flyout-id="' + flyoutId + '"]');
// Also check if we're moving within the dropdown
const parentMenuItem = this.closest('.horizontal-menu-item');
const isMovingWithinDropdown = relatedTarget && parentMenuItem && parentMenuItem.contains(relatedTarget);
console.log('Submenu item mouseleave - moving to flyout:', isMovingToFlyout);
console.log('Submenu item mouseleave - moving within dropdown:', isMovingWithinDropdown);
if (!isMovingToFlyout && !isMovingWithinDropdown) {
this.nestedLeaveTimer = setTimeout(() => {
this.classList.remove('expanded');
// Hide the flyout
if (flyoutId) {
const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + flyoutId + '"]');
if (nestedDropdown) {
nestedDropdown.style.display = 'none';
nestedDropdown.style.opacity = '0';
nestedDropdown.style.visibility = 'hidden';
}
}
// Now that the flyout is closed, allow the parent dropdown to close
const parentMenuItem = this.closest('.horizontal-menu-item');
if (parentMenuItem) {
setTimeout(() => {
const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]');
if (!activeFlyout) {
parentMenuItem.classList.remove('expanded');
const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon');
if (currentIcon) currentIcon.classList.remove('rotated');
}
}, 50);
}
}, 200);
} else {
console.log('Submenu item mouseleave - not starting timer (moving to flyout)');
}
});
nestedSubMenu.addEventListener('mouseenter', function() {
console.log('Flyout mouseenter - clearing timers');
clearTimeout(nestedLeaveTimer);
// Clear the parent dropdown's leave timer to keep it open
const flyoutId = this.getAttribute('data-flyout-id');
if (flyoutId) {
const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]');
if (parentSubItem) {
const parentMenuItem = parentSubItem.closest('.horizontal-menu-item');
if (parentMenuItem && parentMenuItem.leaveTimer) {
console.log('Clearing parent menu item leave timer');
clearTimeout(parentMenuItem.leaveTimer);
parentMenuItem.leaveTimer = null;
} else {
console.log('No parent menu item leave timer to clear');
}
if (parentSubItem.nestedLeaveTimer) {
console.log('Clearing submenu item nested leave timer');
clearTimeout(parentSubItem.nestedLeaveTimer);
parentSubItem.nestedLeaveTimer = null;
}
}
}
});
nestedSubMenu.addEventListener('mouseleave', function() {
const flyoutId = this.getAttribute('data-flyout-id');
if (flyoutId) {
const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]');
if (parentSubItem) {
nestedLeaveTimer = setTimeout(() => {
parentSubItem.classList.remove('expanded');
this.style.display = 'none';
this.style.opacity = '0';
this.style.visibility = 'hidden';
}, 200);
}
}
});
}
}
}
// Click listener for navigation (only if no children or if it's a direct link)
if (!subMenuItem.classList.contains('has-children') || galleryItem.isPage) {
subMenuItem.addEventListener('click', async (event) => {
event.stopPropagation();
const clickedItemId = galleryItem.id;
// Check if it's an external URL first
if (galleryItem.isExternal && galleryItem.url) {
window.open(galleryItem.url, '_blank', 'noopener,noreferrer');
return;
}
if (galleryItem.isPage) {
if (typeof loadPage === 'function') {
loadPage(clickedItemId, event);
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("SubMenu Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId);
setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50);
}
}
} else { // It's a gallery
if (typeof loadGallery === 'function') {
console.log("SubMenu Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId);
await loadGallery(clickedItemId, event);
console.log("SubMenu Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay.");
if (typeof updateActiveStatesHorizontal === 'function') {
setTimeout(() => {
console.log("[Delayed Update from SubMenu Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId);
updateActiveStatesHorizontal(clickedItemId);
}, 100);
}
}
}
// Close all parent dropdowns and flyouts after navigation
document.querySelectorAll('.horizontal-menu-item.expanded, .horizontal-submenu-item.expanded').forEach((openItem) => {
openItem.classList.remove('expanded');
const icon = openItem.querySelector('.icon.toggle-icon');
if (icon) icon.classList.remove('rotated');
});
// Close all flyouts
document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => {
flyout.style.display = 'none';
flyout.style.opacity = '0';
flyout.style.visibility = 'hidden';
});
});
}
return subMenuItem;
}
/**
* Updates the 'active' class for items in the horizontal menu.
*/
function updateActiveStatesHorizontal(forcedActiveId = null) {
// Prioritize forcedActiveId if provided, otherwise use global state.
// Ensure the ID is treated as a number for comparison.
const targetId = forcedActiveId !== null
? parseInt(String(forcedActiveId))
: parseInt(String(window.activeGalleryId || activeGalleryId));
console.log("[Horizontal Menu Update] Initiated. Target Active ID:", targetId,
(forcedActiveId !== null ? `(Forced: ${forcedActiveId})` : "(Global)"),
"Type:", typeof targetId);
const menuItems = document.querySelectorAll('.horizontal-menu-item, .horizontal-submenu-item');
if (menuItems.length === 0 && !isNaN(targetId)) { // Check if targetId is a valid number
console.warn("[Horizontal Menu Update] No horizontal menu items found in DOM to update. Target ID was:", targetId);
return;
}
let activeItemSet = false;
menuItems.forEach(item => {
const itemIdStr = item.dataset.id;
if (!itemIdStr) {
// console.warn("[Horizontal Menu Update] Menu item found without a data-id attribute:", item);
return;
}
const itemId = parseInt(itemIdStr);
if (itemId === targetId) {
if (!item.classList.contains('active')) {
item.classList.add('active');
console.log(`[Horizontal Menu Update] ADDED .active to item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`);
}
activeItemSet = true;
} else {
if (item.classList.contains('active')) {
item.classList.remove('active');
console.log(`[Horizontal Menu Update] REMOVED .active from item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`);
}
}
});
if (!isNaN(targetId) && !activeItemSet) { // Check if targetId is a valid number
console.warn(`[Horizontal Menu Update] Target Active ID was ${targetId}, but NO matching horizontal menu item was made active. Check data-id attributes and item visibility.`);
}
const activeSubItem = document.querySelector('.horizontal-submenu-item.active');
if (activeSubItem) {
const parentTopItem = activeSubItem.closest('.horizontal-menu-item.has-children');
if (parentTopItem && !parentTopItem.classList.contains('active')) {
// parentTopItem.classList.add('active-parent'); // Optional class for styling parent
}
}
console.log("[Horizontal Menu Update] Completed for Target ID:", targetId);
}
// Listener for closing dropdowns when clicking outside
document.addEventListener('click', function(event) {
if (currentMenuLayout === 'horizontal') {
const openSubmenus = document.querySelectorAll('.horizontal-menu-item.expanded');
let clickedInsideSubmenuOrParent = false;
openSubmenus.forEach(submenuContainer => {
// Check if click is on the submenu container OR its direct parent menu item
if (submenuContainer.contains(event.target) ||
(submenuContainer.parentElement && submenuContainer.parentElement.contains(event.target) && submenuContainer.parentElement.classList.contains('horizontal-menu-item'))) {
clickedInsideSubmenuOrParent = true;
}
});
// If click is on another top-level item that is NOT expanded, also don't close
const targetMenuItem = event.target.closest('.horizontal-menu-item');
if (targetMenuItem && !targetMenuItem.classList.contains('expanded') && targetMenuItem.classList.contains('has-children')) {
clickedInsideSubmenuOrParent = true; // Don't close if clicking another parent to open it
}
if (!clickedInsideSubmenuOrParent) {
openSubmenus.forEach(submenuContainer => {
submenuContainer.classList.remove('expanded');
});
}
}
});
document.addEventListener('menuLayoutChanged', function(e) {
const newLayout = e.detail.layout;
console.log('EVENT: menuLayoutChanged detected. New layout:', newLayout, 'Current isEditing state:', isEditing);
// Update the global currentMenuLayout variable if you have one
if (typeof currentMenuLayout !== 'undefined') {
currentMenuLayout = newLayout;
}
const sidebar = document.querySelector('.sidebar'); // Ensure sidebar DOM element is selected
// Always clear and re-render the appropriate menu structure for the new layout.
// This is important for style changes (like font size) that affect dimensions.
const galleryTreeContainer = document.getElementById('galleryTree');
const mobileHeaderContent = document.querySelector('.mobile-header-content');
if (galleryTreeContainer) {
galleryTreeContainer.innerHTML = ''; // Clear existing tree
}
if (mobileHeaderContent) {
const horizontalMenuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container');
if (horizontalMenuContainer) {
horizontalMenuContainer.innerHTML = ''; // Clear only items, not the container itself
}
}
// Render the correct menu structure based on the new layout
if (newLayout === 'horizontal') {
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
}
// CSS rules will handle sidebar visibility based on .edit-mode-active and .menu-layout-horizontal
} else { // 'sidebar' or 'top'
if (typeof renderGalleries === 'function') {
renderGalleries();
}
// CSS rules will handle sidebar visibility
}
// If currently in edit mode, ensure the UI components for editing are correctly displayed
// for the new layout, without fully toggling the mode off and on.
if (isEditing && typeof window.updateGlobalEditState === 'function') {
console.log('menuLayoutChanged: In edit mode. Ensuring edit UI is consistent for new layout.');
// 1. Forcefully ensure global state and body/sidebar classes are correct for edit mode.
// updateGlobalEditState should handle adding .edit-mode-active to body and .editing to sidebar.
window.updateGlobalEditState(true);
// 2. Re-initialize sortables. It's often safer to destroy existing ones first.
if (typeof initializeNestedSortables === 'function') {
if (typeof destroyNestedSortables === 'function') {
destroyNestedSortables();
}
setTimeout(initializeNestedSortables, 100); // Delay for DOM updates
}
// Also reinitialize for SidebarManager if it exists and handles its own sortables
if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') {
setTimeout(() => window.SidebarManager._reinitializeNestedSortables(), 150);
}
// 3. Ensure edit controls (the top bar of buttons in the sidebar) are visible
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.style.display = 'flex'; // Or your default display type for these controls
editControls.classList.add('visible');
}
// 4. If the layout is now horizontal, the sidebar (for editing) should be visible.
// The CSS rule `body.menu-layout-horizontal.edit-mode-active .sidebar { display: block !important; }`
// should handle this. This log is for confirmation.
if (newLayout === 'horizontal' && sidebar) {
console.log('menuLayoutChanged: Horizontal layout in edit mode, sidebar should be visible via CSS.');
}
// 5. If PageManager is active and a page is loaded, ensure its edit mode is also set.
if (window.PageManager && typeof window.PageManager.setEditMode === 'function' &&
window.PageManager.getCurrentPageId && window.PageManager.getCurrentPageId()) {
window.PageManager.setEditMode(true);
}
} else if (!isEditing) {
// If not in edit mode, CSS should handle the view state.
// This block can be used for any explicit view mode adjustments if CSS isn't sufficient.
console.log('menuLayoutChanged: Not in edit mode. CSS will handle view state.');
}
// Update mobile title and close mobile menu if open, regardless of edit mode
if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
});
// Make sure getFirstSidebarElementForHeader is available if called from other scripts
if (typeof window.getFirstSidebarElementForHeader === 'undefined') {
window.getFirstSidebarElementForHeader = getFirstSidebarElementForHeader;
}
// Make sure getFirstSocialElementForHeader is available if called from other scripts
if (typeof window.getFirstSocialElementForHeader === 'undefined') {
window.getFirstSocialElementForHeader = getFirstSocialElementForHeader;
}
// Make sure renderSocialIcons is available if called from other scripts
if (typeof window.renderSocialIcons === 'undefined') {
window.renderSocialIcons = renderSocialIcons;
}
if (typeof window.renderHorizontalMenu === 'undefined') {
window.renderHorizontalMenu = renderHorizontalMenu;
}
if (typeof window.createHorizontalMenuItem === 'undefined') {
window.createHorizontalMenuItem = createHorizontalMenuItem;
}
if (typeof window.createHorizontalSubMenuItem === 'undefined') {
window.createHorizontalSubMenuItem = createHorizontalSubMenuItem;
}
if (typeof window.updateActiveStatesHorizontal === 'undefined') {
window.updateActiveStatesHorizontal = updateActiveStatesHorizontal;
}
// Ensure toggleEditMode is globally available if it's the primary one.
if (typeof window.toggleEditMode === 'undefined' || window.toggleEditMode.toString().length < 100) { // Heuristic to check if it's a placeholder
window.toggleEditMode = toggleEditMode;
}
// Helper function to toggle edit controls visibility
function toggleEditControlsVisibility(show) {
const editControls = document.querySelector('.edit-controls');
if (editControls) {
if (show) {
editControls.classList.add('visible');
editControls.style.display = 'flex';
} else {
editControls.classList.remove('visible');
editControls.style.display = 'none';
}
}
}
document.addEventListener('keydown', function(e) {
if (e.key === '+' || e.keyCode === 187 && e.shiftKey) {
// Check if user is already logged in and is admin
const token = window.didToken || localStorage.getItem('hydra_auth_token');
const isAdmin = window.isAdmin || localStorage.getItem('hydra_is_admin') === 'true';
if (token && isAdmin) {
// User is already logged in - toggle edit mode instead of showing login prompt
console.log('User already logged in - toggling edit mode');
if (typeof window.toggleEditMode === 'function') {
window.toggleEditMode();
} else if (typeof toggleEditMode === 'function') {
toggleEditMode();
} else {
console.warn('toggleEditMode function not found');
}
} else {
// User is not logged in - show login prompt
startOTPLogin();
}
e.preventDefault();
}
});
// Email OTP Authentication with Enhanced Debugging
// Add this function to the client-side code
// Improved client-side backup function with better token handling
async function createLoginBackup() {
try {
console.log('Attempting to create login backup...');
// Get authentication info from multiple possible sources
let token = window.didToken || localStorage.getItem('hydra_auth_token');
const email = localStorage.getItem('hydra_auth_email') ||
(window.userMetadata ? window.userMetadata.email : '') ||
'';
// Check if we have authentication
if (!token) {
console.error('No auth token available for backup');
return;
}
if (!email) {
console.error('No email available for backup - make sure it was stored during login');
return;
}
console.log(`Creating backup with token (${token.length} chars) for ${email}`);
// Format the token correctly - VERY IMPORTANT
// If it's already a hydra token (starts with hydra:), use it as is
// Otherwise, add the hydra: prefix
const formattedToken = token.startsWith('hydra:') ? token : `hydra:${token}`;
console.log(`Using formatted token: ${formattedToken.substring(0, 15)}...`);
// Get the API URL with preview handling if needed
const backupUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/create-backup') :
'/api/create-backup';
console.log(`Sending backup request to: ${backupUrl}`);
// IMPORTANT: Make sure to add the Bearer prefix to the Authorization header
const response = await fetch(backupUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${formattedToken}`,
'Content-Type': 'application/json',
'X-User-Email': email, // Include email as fallback
'X-API-Request': 'true' // Mark as API request
}
});
// Handle response
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
// Try to parse as JSON
errorData = JSON.parse(errorText);
console.error('Backup failed:', errorData.error, errorData);
} catch {
// Not JSON, log raw text
console.error('Backup failed with status', response.status, errorText);
}
return;
}
// Parse successful response
const result = await response.json();
console.log(`✅ Backup created successfully: ${result.backup}`);
} catch (error) {
console.error('Error in backup process:', error);
}
}
// Initialize Magic with detailed logging
function initMagic() {
try {
// If Magic is already initialized, return it
if (window.magic) {
return window.magic;
}
console.log('Initializing Magic SDK...');
// Initialize with minimal options
const magic = new Magic('pk_live_E0B68BA6DCA8C75F', {
testMode: false
});
// Store reference globally
window.magic = magic;
console.log('Magic SDK initialized successfully');
return magic;
} catch (error) {
console.error('Error initializing Magic:', error);
throw error;
}
}
// Start OTP login flow with proper event handling
async function startOTPLogin() {
try {
// Initialize Magic
const magic = initMagic();
// Get email from user
const email = prompt('Please enter your email:');
if (!email) return; // User cancelled
console.log('Starting Email OTP login for:', email);
showLoadingOverlay('Sending verification code...');
// Check if Magic's event enum types are available - for newer Magic SDK versions
// If not available, fall back to string-based events
const useEnums = typeof LoginWithEmailOTPEventOnReceived !== 'undefined' &&
typeof LoginWithEmailOTPEventEmit !== 'undefined';
console.log('Using enum-based events:', useEnums);
// Create a timeout handler
let emailSentTimeout = setTimeout(() => {
console.log('No email-otp-sent event received, using fallback UI');
hideLoadingOverlay();
showOTPInputOverlay(email);
}, 10000);
// Create OTP login handle with showUI: false
const handle = magic.auth.loginWithEmailOTP({
email,
showUI: false,
deviceCheckUI: false
});
// Track authentication state
window.pendingAuth = {
email,
handle
};
// Listen for events - using both enum-based and string-based event handling
console.log('Setting up OTP event handlers...');
// Email OTP sent event
if (useEnums) {
handle.on(LoginWithEmailOTPEventOnReceived.EmailOTPSent, () => {
console.log('EVENT: EmailOTPSent (enum) - Email was sent successfully');
clearTimeout(emailSentTimeout);
hideLoadingOverlay();
showOTPInputOverlay(email);
});
}
// Also listen for string-based event (for backward compatibility)
handle.on('email-otp-sent', () => {
console.log('EVENT: email-otp-sent - Email was sent successfully');
clearTimeout(emailSentTimeout);
hideLoadingOverlay();
showOTPInputOverlay(email);
});
// Invalid OTP event
if (useEnums) {
handle.on(LoginWithEmailOTPEventOnReceived.InvalidEmailOtp, () => {
console.log('EVENT: InvalidEmailOtp (enum) - Invalid code entered');
showErrorMessage('Invalid verification code. Please try again.');
updateOTPInputForRetry();
});
}
// Also listen for string-based event
handle.on('invalid-email-otp', () => {
console.log('EVENT: invalid-email-otp - Invalid code entered');
showErrorMessage('Invalid verification code. Please try again.');
updateOTPInputForRetry();
});
function handleLoginSuccess(result) {
console.log('OTP login successful!');
hideOTPInputOverlay();
hideLoadingOverlay();
// Store the didToken
window.didToken = result;
console.log('DID Token received:', result ? (result.substring(0, 20) + '...') : 'none');
// Set isAuthenticated globally
window.isAuthenticated = true;
// Get user metadata
magic.user.getInfo().then(userInfo => {
window.userMetadata = userInfo;
console.log('User info retrieved:', userInfo);
// Call checkAdminStatus with the token
return checkAdminStatus(result);
}).then(() => {
console.log('Admin status check completed');
// Show success message
showSuccessMessage('Login successful!');
// IMPROVED UI UPDATE APPROACH - Use multiple techniques for redundancy
// 1. Add state classes to body
document.body.classList.add('hydra-authenticated');
if (window.isAdmin) {
document.body.classList.add('hydra-admin');
}
// 2. Direct DOM manipulation
const logoutButton = document.getElementById('logoutButton');
const editButton = document.getElementById('editButton');
// Create new buttons if they don't exist
if (!logoutButton) {
console.log('Creating logout button');
const newLogoutButton = document.createElement('button');
newLogoutButton.id = 'logoutButton';
newLogoutButton.className = 'btn';
newLogoutButton.textContent = 'Logout';
newLogoutButton.onclick = logout;
newLogoutButton.style.display = 'block';
// Add to sidebar header
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.appendChild(newLogoutButton);
} else {
document.body.appendChild(newLogoutButton);
}
} else {
// Force display of existing button
logoutButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;';
}
if (!editButton && window.isAdmin) {
console.log('Creating edit button');
const newEditButton = document.createElement('button');
newEditButton.id = 'editButton';
newEditButton.className = 'btn btn-primary';
newEditButton.innerHTML = `
Edit
`;
newEditButton.onclick = toggleEditMode;
newEditButton.style.display = 'block';
// Add to sidebar header
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.appendChild(newEditButton);
} else {
document.body.appendChild(newEditButton);
}
} else if (editButton && window.isAdmin) {
// Force display of existing button
editButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;';
}
// 3. Dispatch event for other listeners
document.dispatchEvent(new CustomEvent('login-complete', {
detail: {
isAdmin: window.isAdmin,
email: window.userMetadata ? window.userMetadata.email : null
}
}));
// Clear pending auth
window.pendingAuth = null;
}).catch(error => {
console.error('Error in login success handling:', error);
showErrorMessage('Error completing login: ' + error.message);
});
}
// Completion event
handle.on('done', async (result) => {
console.log('OTP login successful!');
hideOTPInputOverlay();
hideLoadingOverlay();
handleLoginSuccess();
// Store the didToken
window.didToken = result;
console.log('DID Token received:', result ? (result.substring(0, 20) + '...') : 'none');
// Set isAuthenticated globally
window.isAuthenticated = true;
document.body.classList.add('hydra-authenticated');
// Get user metadata
try {
const userInfo = await magic.user.getInfo();
window.userMetadata = userInfo;
console.log('User info retrieved:', userInfo);
} catch (error) {
console.error('Error getting user info:', error);
}
// Call checkAdminStatus with the token
try {
if (typeof checkAdminStatus === 'function') {
await checkAdminStatus(result);
} else {
console.warn('checkAdminStatus function not available');
}
} catch (error) {
console.error('Error checking admin status:', error);
}
// Show success message
showSuccessMessage('Login successful!');
// Get the ID token (JWT) for API calls - this contains user info
try {
const idToken = await magic.user.getIdToken();
console.log('ID Token received, length:', idToken ? idToken.length : 0);
console.log('ID Token format check - contains dots:', idToken ? idToken.includes('.') : false);
console.log('ID Token prefix:', idToken ? idToken.substring(0, 20) + '...' : 'none');
// Check if this is actually a JWT token (should contain dots)
if (idToken && idToken.includes('.')) {
console.log('Got proper JWT token from Magic.link');
localStorage.setItem('hydra_auth_token', idToken);
} else {
console.log('ID token is not JWT format, trying to get user info...');
// If not JWT, get user metadata and create a simple token
const metadata = await magic.user.getInfo();
console.log('User metadata:', metadata);
if (metadata && metadata.email) {
// Create a simple token with user email
const userToken = {
email: metadata.email,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
};
// Create a proper JWT-like structure: header.payload.signature
const header = btoa(JSON.stringify({ typ: 'JWT', alg: 'none' }));
const payload = btoa(JSON.stringify(userToken));
const signature = 'nosignature';
const jwtToken = header + '.' + payload + '.' + signature;
console.log('Created JWT-like token with email:', metadata.email);
localStorage.setItem('hydra_auth_token', jwtToken);
localStorage.setItem('hydra_auth_email', metadata.email);
} else {
throw new Error('Could not get user metadata');
}
}
// Also store DID token separately if needed
localStorage.setItem('hydra_did_token', result);
} catch (error) {
console.error('Error getting ID token:', error);
// Fallback to DID token if ID token fails
localStorage.setItem('hydra_auth_token', result);
}
// Update UI directly with classes
document.body.classList.add('hydra-authenticated');
if (isAdmin) {
document.body.classList.add('hydra-admin');
}
// Clear pending auth
window.pendingAuth = null;
// Run existing checkAuth if available
if (typeof checkAuth === 'function') {
console.log('Running existing checkAuth function');
setTimeout(checkAuth, 500);
}
});
// Error event
handle.on('error', (error) => {
console.error('EVENT: error - OTP login error:', error);
hideLoadingOverlay();
hideOTPInputOverlay();
showErrorMessage('Login failed: ' + (error.message || 'Unknown error'));
// Clear timeout if still active
clearTimeout(emailSentTimeout);
// Clean up
window.pendingAuth = null;
});
console.log('OTP event handlers set up successfully');
} catch (error) {
console.error('Error starting OTP login:', error);
hideLoadingOverlay();
hideOTPInputOverlay();
showErrorMessage('Error starting login: ' + error.message);
}
}
// Submit OTP code with support for enum-based events
function submitOTP(code) {
try {
// Validate code input
if (!code || code.trim() === '') {
showErrorMessage('Please enter the verification code.');
return;
}
// Check if we have a pending authentication
if (!window.pendingAuth || !window.pendingAuth.handle) {
showErrorMessage('No active login session. Please try again.');
hideOTPInputOverlay();
return;
}
console.log('Submitting OTP code...');
showLoadingOverlay('Verifying code...');
// Check if enum types are available
const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined';
// Verify the OTP code
if (useEnums) {
window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.VerifyEmailOtp, code);
} else {
window.pendingAuth.handle.emit('verify-email-otp', code);
}
} catch (error) {
console.error('Error submitting OTP:', error);
hideLoadingOverlay();
showErrorMessage('Error verifying code: ' + error.message);
}
}
// Cancel OTP login with support for enum-based events
function cancelOTPLogin() {
try {
// Check if we have a pending authentication
if (window.pendingAuth && window.pendingAuth.handle) {
// Check if enum types are available
const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined';
// Emit cancel event
if (useEnums) {
window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.Cancel);
} else {
window.pendingAuth.handle.emit('cancel');
}
console.log('OTP login cancelled');
}
// Clean up
window.pendingAuth = null;
// Hide any overlays
hideOTPInputOverlay();
hideLoadingOverlay();
} catch (error) {
console.error('Error cancelling OTP login:', error);
}
}
// Update OTP input for retry
function updateOTPInputForRetry() {
const otpInput = document.getElementById('otp-input');
if (otpInput) {
otpInput.value = '';
otpInput.focus();
// Add a shake animation for visual feedback
otpInput.classList.add('shake');
// Remove the animation class after it completes
setTimeout(() => {
otpInput.classList.remove('shake');
}, 500);
}
// Hide loading overlay if it's visible
hideLoadingOverlay();
}
// Show OTP input overlay - improved version
function showOTPInputOverlay(email) {
// Remove existing overlay if any
hideOTPInputOverlay();
// Create overlay
const overlay = document.createElement('div');
overlay.id = 'otp-input-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '10000';
// Create content box
const content = document.createElement('div');
content.style.backgroundColor = 'white';
content.style.padding = '30px';
content.style.borderRadius = '0px';
content.style.maxWidth = '400px';
content.style.width = '90%';
content.style.textAlign = 'center';
content.style.boxShadow = '0 4px 20px rgba(0,0,0,0.3)';
// Add logo
const logo = document.createElement('img');
logo.src = 'https://cdn.neonsky.app/neon-sky-logo.png';
logo.alt = 'Neon Sky Logo';
logo.style.width = '250px';
logo.style.marginBottom = '15px';
// Add title
const title = document.createElement('h3');
title.textContent = '';
title.style.margin = '0 0 15px 0';
title.style.fontSize = '20px';
// Add description
const description = document.createElement('p');
description.innerHTML = `We've sent a verification code to ${email}. Please check your email and enter the code below:`;
description.style.marginBottom = '20px';
description.style.fontSize = '14px';
description.style.lineHeight = '1.4';
description.style.color = '#555';
// Append elements
content.appendChild(logo); // Add logo at the top
content.appendChild(title);
content.appendChild(description);
overlay.appendChild(content);
document.body.appendChild(overlay);
// Create form
const form = document.createElement('form');
form.onsubmit = function(e) {
e.preventDefault();
const otpInput = document.getElementById('otp-input');
if (otpInput && otpInput.value) {
submitOTP(otpInput.value);
}
};
// Add animation styles
if (!document.getElementById('otp-animation-styles')) {
const style = document.createElement('style');
style.id = 'otp-animation-styles';
style.textContent = `
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.shake {
animation: shake 0.5s;
}
`;
document.head.appendChild(style);
}
// Create input
const input = document.createElement('input');
input.id = 'otp-input';
input.type = 'text';
input.placeholder = 'Enter code';
input.pattern = '[0-9]*'; // Numbers only
input.inputMode = 'numeric'; // Show numeric keyboard on mobile
input.autocomplete = 'one-time-code'; // For OTP autocomplete
input.style.width = '100%';
input.style.padding = '12px';
input.style.fontSize = '20px';
input.style.textAlign = 'center';
input.style.letterSpacing = '4px';
input.style.fontWeight = 'bold';
input.style.border = '2px solid #ddd';
input.style.borderRadius = '0px';
input.style.marginBottom = '20px';
input.style.boxSizing = 'border-box';
form.appendChild(input);
// Create button container
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'space-between';
buttonContainer.style.gap = '10px';
// Create verify button
const verifyButton = document.createElement('button');
verifyButton.type = 'submit';
verifyButton.textContent = 'Verify';
verifyButton.style.flex = '1';
verifyButton.style.padding = '10px';
verifyButton.style.backgroundColor = '#4682B4';
verifyButton.style.color = 'white';
verifyButton.style.border = 'none';
verifyButton.style.borderRadius = '0px';
verifyButton.style.fontSize = '16px';
verifyButton.style.cursor = 'pointer';
buttonContainer.appendChild(verifyButton);
// Create cancel button
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.textContent = 'Cancel';
cancelButton.style.flex = '1';
cancelButton.style.padding = '10px';
cancelButton.style.backgroundColor = '#f1f1f1';
cancelButton.style.color = '#333';
cancelButton.style.border = 'none';
cancelButton.style.borderRadius = '0px';
cancelButton.style.fontSize = '16px';
cancelButton.style.cursor = 'pointer';
cancelButton.onclick = function() {
cancelOTPLogin();
};
buttonContainer.appendChild(cancelButton);
form.appendChild(buttonContainer);
// Add resend option
const resendContainer = document.createElement('div');
resendContainer.style.marginTop = '15px';
resendContainer.style.fontSize = '14px';
resendContainer.style.color = '#666';
const resendText = document.createElement('span');
resendText.textContent = "Didn't receive the code? ";
const resendLink = document.createElement('a');
resendLink.textContent = "Resend";
resendLink.href = "#";
resendLink.style.color = '#4682B4';
resendLink.style.textDecoration = 'none';
resendLink.onclick = function(e) {
e.preventDefault();
cancelOTPLogin();
setTimeout(() => {
startOTPLogin();
}, 500);
};
resendContainer.appendChild(resendText);
resendContainer.appendChild(resendLink);
form.appendChild(resendContainer);
content.appendChild(form);
overlay.appendChild(content);
document.body.appendChild(overlay);
// Focus the input
setTimeout(() => {
input.focus();
}, 100);
}
// Hide OTP input overlay
function hideOTPInputOverlay() {
const overlay = document.getElementById('otp-input-overlay');
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
// Utility function to show loading overlay
function showLoadingOverlay(message = 'Loading...') {
// Remove any existing overlay first
hideLoadingOverlay();
const overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '10000';
const content = document.createElement('div');
content.style.backgroundColor = 'white';
content.style.padding = '30px';
content.style.borderRadius = '0px';
content.style.textAlign = 'center';
content.style.maxWidth = '400px';
content.style.width = '90%';
// Add SVG loader instead of spinner
const loaderContainer = document.createElement('div');
loaderContainer.style.margin = '0 auto 20px auto';
loaderContainer.style.color = '#444444'; // Dark grey color
// Add SVG animation
loaderContainer.innerHTML = `
`;
content.appendChild(loaderContainer);
// Add message
const messageEl = document.createElement('p');
messageEl.textContent = message;
messageEl.style.margin = '0';
messageEl.style.fontSize = '16px';
content.appendChild(messageEl);
overlay.appendChild(content);
document.body.appendChild(overlay);
}
// Utility function to hide loading overlay
function hideLoadingOverlay() {
const overlay = document.getElementById('loading-overlay');
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
// Utility function to show success message
function showSuccessMessage(message, duration = 3000) {
const toast = document.createElement('div');
toast.id = 'success-toast';
toast.style.position = 'fixed';
toast.style.top = '20px';
toast.style.left = '50%';
toast.style.transform = 'translateX(-50%)';
toast.style.backgroundColor = '#4682B4';
toast.style.color = 'white';
toast.style.padding = '12px 24px';
toast.style.borderRadius = '0px';
toast.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
toast.style.zIndex = '10000';
toast.style.fontSize = '16px';
toast.textContent = message;
document.body.appendChild(toast);
// Remove after duration
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, duration);
}
// Utility function to show error message
function showErrorMessage(message, duration = 5000) {
const toast = document.createElement('div');
toast.id = 'error-toast';
toast.style.position = 'fixed';
toast.style.top = '20px';
toast.style.left = '50%';
toast.style.transform = 'translateX(-50%)';
toast.style.backgroundColor = '#e74c3c';
toast.style.color = 'white';
toast.style.padding = '12px 24px';
toast.style.borderRadius = '0px';
toast.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
toast.style.zIndex = '10000';
toast.style.fontSize = '16px';
toast.textContent = message;
document.body.appendChild(toast);
// Remove after duration
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, duration);
}
// UPDATED: Check authentication status with class-based approach
async function checkAuth() {
try {
if (!magic) {
console.log('Magic not initialized, skipping auth check');
return;
}
// Prevent multiple simultaneous auth checks
if (window._checkingAuth) return;
window._checkingAuth = true;
console.log('Checking auth status...');
isAuthenticated = await magic.user.isLoggedIn();
console.log('isLoggedIn check:', isAuthenticated);
if (isAuthenticated) {
// Add the authenticated class
document.body.classList.add('hydra-authenticated');
// Get user metadata
userMetadata = await magic.user.getInfo();
console.log('User info:', userMetadata);
// Get ID token for API calls (JWT format)
didToken = await magic.user.getIdToken();
console.log('ID token acquired, length:', didToken.length);
// Store token in localStorage
localStorage.setItem('hydra_auth_token', didToken);
// Check if user is admin
await checkAdminStatus(didToken);
} else {
resetAuthUI();
}
// Release lock
window._checkingAuth = false;
} catch (error) {
console.error('Error checking authentication:', error);
window._checkingAuth = false;
resetAuthUI();
}
}
async function checkAdminStatus(token) {
try {
console.log('Checking admin status...');
// Add user email to headers if available
let userEmail = null;
if (window.userMetadata && window.userMetadata.email) {
userEmail = window.userMetadata.email;
console.log('Using email from userMetadata:', userEmail);
} else {
userEmail = localStorage.getItem('hydra_auth_email'); // Fallback to localStorage
if (userEmail) console.log('Using email from localStorage:', userEmail);
}
if (!token) {
console.log('No token provided to checkAdminStatus');
return false; // Indicate failure
}
// Format the token correctly for the API call
let formattedToken = token;
// Ensure Bearer prefix is added for Magic tokens, or use hydra: prefix
if (!token.startsWith('hydra:') && !token.startsWith('Bearer ')) {
formattedToken = `Bearer ${token}`; // Assume Magic/JWT needs Bearer
} else if (token.startsWith('hydra:')) {
formattedToken = `Bearer ${token}`; // API expects Bearer even for hydra tokens
}
// If it already starts with Bearer, use as is
// Get the API URL, handling preview mode
const apiUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/check-admin') :
'/api/check-admin';
console.log('Sending admin check request to:', apiUrl);
// Create request headers
const headers = {
'Authorization': formattedToken,
'X-Hydra-Request': 'true' // Indicate this might be part of Hydra flow
};
if (userEmail) {
headers['X-User-Email'] = userEmail;
}
console.log('Admin check headers:', {
'Authorization': `${formattedToken.substring(0, 20)}...`,
'X-User-Email': userEmail || 'Not available'
});
// Add timeout to the fetch request
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
let data;
try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: headers,
signal: controller.signal
});
clearTimeout(timeoutId); // Clear timeout if fetch succeeds
console.log('Admin check response status:', response.status);
// Get the response as text first for better debugging
const responseText = await response.text();
console.log('Admin check response length:', responseText.length);
console.log('Admin check response text (first 100 chars):', responseText.substring(0, 100) + '...');
try {
data = JSON.parse(responseText);
console.log('Admin check data:', data);
} catch (parseError) {
console.error('Error parsing admin check response:', parseError);
// Handle cases where the response might not be JSON (e.g., HTML error page)
// For preview URLs, let's still assume admin to allow editing flow
if (window.location.hostname === 'preview.neonsky.app') {
console.log('Preview URL detected, assuming admin status due to non-JSON response');
isAdmin = true;
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
forceShowEditUI(); // Attempt to show UI
return true; // Indicate potential success for preview
}
throw new Error(`Failed to parse admin check response: ${responseText.substring(0, 100)}...`);
}
if (!response.ok) {
// Throw an error with details from the parsed JSON if available
throw new Error(`Admin check failed: ${response.status} - ${data?.error || responseText.substring(0, 50)}`);
}
// Process the admin status
isAdmin = data.isAdmin;
window.isAdmin = isAdmin; // Update global flag
// Update classes based on admin status
if (isAdmin) {
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
// Store email if we got it from the server response
if (data.email) {
localStorage.setItem('hydra_auth_email', data.email);
} else if (userEmail) {
localStorage.setItem('hydra_auth_email', userEmail); // Store the email we used
}
} else {
document.body.classList.remove('hydra-admin');
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_email'); // Clear email if not admin
}
// Update siteId if provided
if (data.siteId) {
siteId = data.siteId;
window.siteId = siteId;
if (window.Parameters) window.Parameters.siteId = siteId;
}
// Store new token if provided (Hydra token)
if (data.hydraToken) {
console.log('Received hydra token:', data.hydraToken.substring(0, 10) + '...');
// Store token globally and in localStorage using TokenManager if available
if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') {
window.TokenManager.storeToken(data.hydraToken, data.email || userEmail || '');
} else {
// Fallback storage
const cleanToken = data.hydraToken.startsWith('hydra:') ? data.hydraToken.substring(6) : data.hydraToken;
didToken = cleanToken;
localStorage.setItem('hydra_auth_token', cleanToken);
if (data.email || userEmail) {
localStorage.setItem('hydra_auth_email', data.email || userEmail);
}
}
console.log('Stored credentials in localStorage');
}
// Update UI based on admin status
if (isAdmin) {
console.log('Admin status confirmed, showing edit UI');
forceShowEditUI(); // Ensure UI elements are visible
// --- MODIFICATION START ---
// If layout is horizontal, re-render the menu to show controls
let currentLayout = 'sidebar'; // Default
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
if (currentLayout === 'horizontal') {
console.log('checkAdminStatus: Horizontal layout detected, re-rendering horizontal menu.');
if (typeof renderHorizontalMenu === 'function') {
renderHorizontalMenu();
} else {
console.warn("checkAdminStatus: renderHorizontalMenu function not found.");
}
}
// --- MODIFICATION END ---
createLoginBackup(); // Create backup after successful login/admin check
} else {
console.log('User is not admin, resetting UI.');
resetAuthUI(); // Ensure non-admin UI state
}
return isAdmin; // Return the admin status
} catch (fetchError) {
clearTimeout(timeoutId); // Clear timeout on error as well
console.error('Fetch error during admin check:', fetchError);
// For preview URLs, still allow editing as a fallback
if (window.location.hostname === 'preview.neonsky.app') {
console.log('Preview URL + fetch error, still enabling editing');
isAdmin = true;
window.isAdmin = true;
document.body.classList.add('hydra-admin');
localStorage.setItem('hydra_is_admin', 'true');
forceShowEditUI();
return true; // Indicate potential success for preview
}
// For non-preview, throw the error to indicate failure
throw fetchError;
}
} catch (error) {
console.error('Error in checkAdminStatus:', error);
// Don't show alert here, let the calling function handle UI feedback
// Reset UI to non-admin state on error
resetAuthUI();
return false; // Indicate failure
}
}
// UPDATED: Force showing edit UI with class-based approach
function forceShowEditUI() {
console.log('Forcing admin/auth state for UI');
// Ensure global state flags are set
isAuthenticated = true;
isAdmin = true;
window.isAuthenticated = true;
window.isAdmin = true;
// Add admin/auth classes to body - CSS will handle button visibility
document.body.classList.add('hydra-authenticated', 'hydra-admin');
// Store admin status in localStorage
localStorage.setItem('hydra_is_admin', 'true');
// Show edit controls container ONLY if already in edit mode
if (window.isInEditMode && window.isInEditMode()) {
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.classList.add('visible');
editControls.style.display = 'flex'; // Make sure container is flex
}
// Ensure controls inside items are visible if already editing
document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => {
if (el.closest('.sidebar.editing') || el.closest('.page-container.editing')) {
el.style.display = 'flex';
}
});
// Show logout button in edit controls when in edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'flex';
logoutButton.classList.add('visible');
}
} else {
// Ensure edit controls container is hidden if not in edit mode
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.classList.remove('visible');
editControls.style.display = 'none';
}
// Hide logout button when not in edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
}
console.log('Admin/Auth classes set. CSS should handle button visibility.');
}
// UPDATED: Reset auth UI with class-based approach
function resetAuthUI() {
document.body.classList.remove('hydra-authenticated');
document.body.classList.remove('hydra-admin');
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.style.display = 'none';
editButton.classList.remove('visible');
}
// Remove admin status from localStorage
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_token');
if (isEditing) {
toggleEditMode(); // Exit edit mode if active
}
}
// Debug function - add to your code during testing
function debugUIClasses() {
console.log('Current body classes:', document.body.className);
console.log('hydra-initialized:', document.body.classList.contains('hydra-initialized'));
console.log('hydra-authenticated:', document.body.classList.contains('hydra-authenticated'));
console.log('hydra-admin:', document.body.classList.contains('hydra-admin'));
console.log('edit-mode-active:', document.body.classList.contains('edit-mode-active'));
}
async function logout() {
console.log('Logout initiated');
try {
// Show loading indication (optional)
showLoadingOverlay('Logging out...');
// 1. Properly logout from Magic SDK
if (window.magic) {
try {
await window.magic.user.logout();
console.log('Magic SDK logout successful');
} catch (magicError) {
console.error('Error during Magic logout:', magicError);
// Continue with other logout steps even if Magic SDK logout fails
}
}
// 2. Reset all authentication state variables
window.isAuthenticated = false;
window.isAdmin = false;
window.userMetadata = null;
window.didToken = null;
// 3. Clear all authentication data from localStorage
localStorage.removeItem('hydra_is_admin');
localStorage.removeItem('hydra_auth_token');
localStorage.removeItem('hydra_auth_email');
// 4. Reset all UI state - remove authentication classes from body
document.body.classList.remove('hydra-authenticated');
document.body.classList.remove('hydra-admin');
// 5. If in edit mode, exit it first
if (document.body.classList.contains('edit-mode-active')) {
console.log('Exiting edit mode before logout');
// If toggleEditMode exists, call it to exit edit mode
if (typeof toggleEditMode === 'function') {
try {
toggleEditMode();
} catch (editError) {
console.error('Error exiting edit mode:', editError);
}
}
// Ensure edit mode class is removed regardless
document.body.classList.remove('edit-mode-active');
}
// 6. Ensure all edit UI elements are hidden
// Hide edit and logout buttons
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.classList.remove('visible');
editButton.style.display = 'none';
}
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.classList.remove('visible');
logoutButton.style.display = 'none';
}
// Hide edit controls
const editControls = document.querySelector('.edit-controls');
if (editControls) {
editControls.style.display = 'none';
editControls.classList.remove('visible');
}
// Hide sidebar header if it should be hidden when logged out
const sidebarHeader = document.querySelector('.sidebar-header');
if (sidebarHeader) {
sidebarHeader.style.display = 'none';
}
// Hide all element controls
document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => {
el.style.display = 'none';
});
// 7. Reset editor state if applicable
if (window.Parameters) {
window.Parameters.isInEditor = false;
}
// Hide any editing forms that might be open
const forms = document.querySelectorAll('.add-form, .edit-form, .element-edit-form, #menuStyleEditor, #metadataEditor, #importClassicForm, #sidebarElementForm');
forms.forEach(form => {
form.style.display = 'none';
if (form.classList.contains('visible')) {
form.classList.remove('visible');
}
});
// Remove editing class from sidebar
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.remove('editing');
}
// 8. Clear any page-specific state
if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') {
window.PageManager.clearCurrentPage();
}
// 9. Notify any components that need to know about logout
document.dispatchEvent(new CustomEvent('user-logout', {
detail: { timestamp: Date.now() }
}));
// 10. Show success message
hideLoadingOverlay();
showSuccessMessage('Successfully logged out');
console.log('Logout complete');
// 11. Optional: Refresh the page after a short delay for a clean state
// Uncomment the following lines if you want the page to refresh after logout
/*
setTimeout(() => {
window.location.reload();
}, 1500);
*/
} catch (error) {
console.error('Error during logout process:', error);
hideLoadingOverlay();
showErrorMessage('Error during logout. Please refresh the page.');
}
}
// Ensure the logout button is properly connected to the logout function
document.addEventListener('DOMContentLoaded', function() {
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
// Remove any existing event listeners to avoid duplicates
const newButton = logoutButton.cloneNode(true);
logoutButton.parentNode.replaceChild(newButton, logoutButton);
// Add fresh event listener
newButton.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
logout();
});
}
});
function stopAutoAdvanceTimer() {
if (window.currentAutoAdvanceTimerId) {
clearTimeout(window.currentAutoAdvanceTimerId);
window.currentAutoAdvanceTimerId = null;
console.log('Auto-advance timer stopped.');
}
}
window.stopAutoAdvanceTimer = stopAutoAdvanceTimer;
function toggleEditMode() {
// Prevent multiple simultaneous calls
if (window._editModeToggleInProgress) {
console.log('Edit mode toggle already in progress, skipping');
return;
}
window._editModeToggleInProgress = true;
console.log("toggleEditMode called. Current window.isEditing:", window.isEditing, "Current window.isInEditMode():", window.isInEditMode ? window.isInEditMode() : 'undefined');
stopAutoAdvanceTimer(); // Assumes this function exists and stops any slideshow/auto-advance
// Authentication and Authorization Checks
if (!window.isAuthenticated && !(localStorage.getItem('hydra_is_admin') === 'true')) {
alert('You must be logged in to edit');
window._editModeToggleInProgress = false;
return;
}
if (localStorage.getItem('hydra_is_admin') === 'true' && !window.isAdmin) {
window.isAdmin = true; // Synchronize global flag
}
if (!window.isAdmin) {
alert('You must be an admin to edit');
window._editModeToggleInProgress = false;
return;
}
const currentEditState = window.isInEditMode ? window.isInEditMode() : (typeof window.isEditing !== 'undefined' ? window.isEditing : false);
const newEditState = !currentEditState;
console.log(`Changing edit mode from ${currentEditState} to: ${newEditState}`);
// Apply body class for edit mode styling
if (newEditState) {
console.log('Entering edit mode - setting body class and theme');
document.body.setAttribute('data-theme', 'pico'); // Optional: if Pico theme is used for edit mode
document.body.classList.add('edit-mode-active');
} else {
console.log('Exiting edit mode - removing body class and theme');
document.body.removeAttribute('data-theme');
document.body.classList.remove('edit-mode-active');
}
// Update the global state and dispatch event
if (typeof window.updateGlobalEditState === 'function') {
window.updateGlobalEditState(newEditState);
} else {
window.isEditing = newEditState; // Fallback
document.dispatchEvent(new CustomEvent('edit-mode-changed', { detail: { editing: newEditState } }));
}
const sidebarEditControls = document.querySelector('.edit-controls');
if (newEditState) {
// Entering edit mode
if (sidebarEditControls) {
sidebarEditControls.style.display = 'flex';
sidebarEditControls.classList.add('visible');
}
// Show logout button in edit controls when entering edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'flex';
logoutButton.classList.add('visible');
}
// Always render the tree-like menu in the sidebar for editing
if (typeof window.renderGalleries === 'function') {
console.log('toggleEditMode (Edit): Rendering sidebar menu (gallery tree) for editing.');
window.renderGalleries(); // This will show all items, including those with visible:false
}
setTimeout(() => {
if (typeof window.initializeNestedSortables === 'function') {
console.log('Initializing nested sortables for edit mode');
window.initializeNestedSortables();
}
if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') {
window.SidebarManager._reinitializeNestedSortables();
}
}, 150);
// If the currently active item was invisible, reload it now that we are in edit mode.
if(window.galleries && window.activeGalleryId){
const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId);
if (currentGalleryItem && currentGalleryItem.visible === false) {
console.log('toggleEditMode (Edit): Active item was invisible, reloading it.');
if (currentGalleryItem.isPage && window.loadPage) {
window.loadPage(window.activeGalleryId);
} else if(window.loadGallery) {
window.loadGallery(window.activeGalleryId);
}
}
}
if(typeof window.showStyleEditor === 'function') window.showStyleEditor(false); // Ensure style editor is hidden initially
} else {
// Exiting edit mode (going to Live view)
if (sidebarEditControls) {
sidebarEditControls.style.display = 'none';
sidebarEditControls.classList.remove('visible');
}
// Hide logout button when exiting edit mode
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.style.display = 'none';
logoutButton.classList.remove('visible');
}
if(typeof window.closeAllForms === 'function') window.closeAllForms();
if (typeof window.destroyNestedSortables === 'function') {
window.destroyNestedSortables();
} else if (window.sortableInstances && Array.isArray(window.sortableInstances)) {
window.sortableInstances.forEach(instance => instance.destroy());
window.sortableInstances = [];
}
// Re-render the menu based on the actual view mode layout.
// These functions should internally handle not showing items with visible:false.
let currentLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar';
}
if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') {
window.renderHorizontalMenu();
} else if (typeof window.renderGalleries === 'function') {
window.renderGalleries();
}
// Handle the currently active gallery/page.
if (window.galleries && window.activeGalleryId) {
const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId);
if (currentGalleryItem) {
// The content for currentGalleryItem should remain loaded,
// even if currentGalleryItem.visible is false.
// The menu rendering above will hide it from the list if it's invisible.
// Apply page-specific menu hiding rules.
const bodyEl = document.body;
// When exiting edit mode, isInEditMode() will effectively be false for this check.
if (currentGalleryItem.hideMenuOnPage) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page');
}
// Ensure active states in the (potentially now filtered) menu are updated.
// If the active item is invisible, it won't be marked active in the menu, which is fine.
if(typeof window.updateActiveStates === 'function') window.updateActiveStates();
if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal();
} else {
// activeGalleryId points to a non-existent item, so clear it and the content.
console.log('toggleEditMode (Live): activeGalleryId points to a non-existent item. Clearing content.');
window.activeGalleryId = null;
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) galleryContainer.innerHTML = '';
if(typeof window.updateActiveStates === 'function') window.updateActiveStates();
if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal();
}
}
// If, after all that, no content is effectively active (e.g., initial load to root,
// or activeGalleryId became null because the item was deleted)
// then try to load a default page (home page or first visible gallery).
const galleryContainer = document.querySelector('.gallery-container');
// Check if activeGalleryId is null OR if it's set but the container is empty (e.g. item was deleted)
const noActiveContent = window.activeGalleryId === null ||
(galleryContainer && galleryContainer.innerHTML.trim() === '');
if (noActiveContent && window.galleries) {
console.log("toggleEditMode (Live): No active content, attempting to load default page.");
const homePage = window.galleries.find(g => g.isHomePage === true);
let loadedDefault = false;
if (homePage) {
// Load home page regardless of its visibility status for this default loading logic
console.log(`toggleEditMode (Live): Loading home page: ${homePage.title}`);
if (homePage.isPage && window.loadPage) { window.loadPage(homePage.id); loadedDefault = true; }
else if (window.loadGallery) { window.loadGallery(homePage.id); loadedDefault = true; }
}
if (!loadedDefault) {
// If no home page, load the first *visible* gallery/page in live view.
const firstVisibleGallery = window.galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log(`toggleEditMode (Live): No home page, loading first visible item: ${firstVisibleGallery.title}`);
if (firstVisibleGallery.isPage && window.loadPage) {
window.loadPage(firstVisibleGallery.id);
} else if(window.loadGallery) {
window.loadGallery(firstVisibleGallery.id);
}
} else {
console.log('toggleEditMode (Live): No home page and no visible items to load.');
}
}
}
}
window._editModeToggleInProgress = false;
}
/**
* Improved toggleSidebarElementForm - Closes other forms first
*/
function toggleSidebarElementForm() {
const formId = 'sidebarElementForm';
const form = document.getElementById(formId);
if (!form) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Show the form
form.style.display = 'block';
form.classList.add('visible');
// Set this as the current open form
window.currentOpenForm = formId;
}
// New function to add a sidebar element
function addSidebarElement() {
const typeInput = document.querySelector('input[name="sidebarElementType"]:checked');
if (!typeInput) {
alert('Please select an element type');
return;
}
const type = typeInput.value;
// Use the SidebarManager to add the element
if (window.SidebarManager) {
const newElementId = window.SidebarManager.addElement(type);
toggleSidebarElementForm();
if (newElementId) {
// Save the changes to KV store
saveGalleries().then(() => {
console.log(`New ${type} element added successfully with ID: ${newElementId}`);
}).catch(error => {
console.error('Error saving new element:', error);
});
}
} else {
alert('SidebarManager not available');
}
}
// Global variable to store metadata
window.siteMetadata = {
title: '',
description: '',
googleAnalytics: '',
noIndex: false,
favicon: '',
contentLanguage: 'en', // Default to English
copyrightEnabled: false,
copyrightText: ''
};
// Global variable to store uploaded favicon URL temporarily
window.uploadedFaviconUrl = '';
function updateMetadataCopyrightFieldState() {
const toggle = document.getElementById('metadataCopyrightEnabled');
const textInput = document.getElementById('metadataCopyrightText');
const isEnabled = !!(toggle && toggle.checked);
if (textInput) {
textInput.disabled = !isEnabled;
}
// Intentionally leave the surrounding form group in place; the disabled attribute on the input
// provides the visual cue in PicoCSS.
}
/**
* Improved toggleMetadataEditor - Closes other forms first
*/
function toggleMetadataEditor() {
const formId = 'metadataEditor';
const editor = document.getElementById(formId);
if (!editor) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Load current values before showing
loadMetadataValues();
editor.style.display = 'block';
// Set this as the current open form
window.currentOpenForm = formId;
}
// Load metadata values into the form
function loadMetadataValues() {
// Get the current values from the global object
const metadata = window.siteMetadata || {};
console.log('loadMetadataValues: Loading metadata:', JSON.stringify(metadata));
console.log('loadMetadataValues: Google Analytics from metadata:', metadata.googleAnalytics);
console.warn('=== FAVICON LOAD DEBUG ===');
console.warn('window.siteMetadata:', JSON.stringify(window.siteMetadata));
console.warn('metadata object:', JSON.stringify(metadata));
console.warn('metadata.favicon value:', metadata.favicon);
console.warn('metadata.favicon type:', typeof metadata.favicon);
// Populate form fields
document.getElementById('metaTitle').value = metadata.title || '';
document.getElementById('metaDescription').value = metadata.description || '';
document.getElementById('googleAnalytics').value = metadata.googleAnalytics || '';
document.getElementById('noIndexToggle').checked = metadata.noIndex || false;
const copyrightToggle = document.getElementById('metadataCopyrightEnabled');
const copyrightInput = document.getElementById('metadataCopyrightText');
if (copyrightToggle) {
const enabled = !!metadata.copyrightEnabled;
copyrightToggle.checked = enabled;
if (copyrightInput) {
copyrightInput.value = metadata.copyrightText || '';
}
}
updateMetadataCopyrightFieldState();
// Set content language dropdown (defaults to 'en')
const contentLanguageSelect = document.getElementById('contentLanguage');
if (contentLanguageSelect) {
contentLanguageSelect.value = metadata.contentLanguage || 'en';
console.log('Loaded content language:', contentLanguageSelect.value);
}
const faviconInput = document.getElementById('faviconUrl');
if (faviconInput) {
// Check if we have a recently uploaded favicon URL
const faviconValue = metadata.favicon || window.uploadedFaviconUrl || '';
faviconInput.value = faviconValue;
console.warn('Set favicon input value to:', faviconInput.value);
console.warn('Used global uploaded favicon URL:', window.uploadedFaviconUrl);
} else {
console.error('Favicon input element not found!');
}
// Show favicon preview if there's an existing favicon
if (metadata.favicon) {
console.warn('Showing favicon preview for:', metadata.favicon);
showFaviconPreview(metadata.favicon);
} else {
console.warn('No favicon to preview');
}
console.log('loadMetadataValues: Google Analytics field value after setting:', document.getElementById('googleAnalytics').value);
}
// Save metadata values
function saveMetadata() {
// Get values from form
const googleAnalyticsElement = document.getElementById('googleAnalytics');
console.log('Google Analytics element found:', !!googleAnalyticsElement);
console.log('Google Analytics element value:', googleAnalyticsElement ? googleAnalyticsElement.value : 'ELEMENT NOT FOUND');
const faviconInput = document.getElementById('faviconUrl');
const faviconValue = faviconInput ? faviconInput.value : '';
const contentLanguageSelect = document.getElementById('contentLanguage');
const contentLanguageValue = contentLanguageSelect ? contentLanguageSelect.value : 'en';
const copyrightToggle = document.getElementById('metadataCopyrightEnabled');
const copyrightInput = document.getElementById('metadataCopyrightText');
const copyrightEnabled = copyrightToggle ? copyrightToggle.checked : false;
const copyrightText = copyrightInput ? copyrightInput.value.trim() : '';
console.warn('=== FAVICON SAVE DEBUG ===');
console.warn('Favicon input element exists:', !!faviconInput);
console.warn('Favicon input value:', faviconValue);
console.warn('Favicon input value length:', faviconValue.length);
console.warn('=== CONTENT LANGUAGE SAVE ===');
console.warn('Content language selected:', contentLanguageValue);
const metadata = {
title: document.getElementById('metaTitle').value,
description: document.getElementById('metaDescription').value,
googleAnalytics: googleAnalyticsElement ? googleAnalyticsElement.value : '',
noIndex: document.getElementById('noIndexToggle').checked,
favicon: faviconValue,
contentLanguage: contentLanguageValue, // Get from dropdown
copyrightEnabled,
copyrightText
};
console.warn('Metadata object being created:', JSON.stringify(metadata));
console.warn('Favicon in metadata object:', metadata.favicon);
// Update global metadata object immediately
window.siteMetadata = metadata;
console.log("Updated window.siteMetadata:", JSON.stringify(window.siteMetadata));
if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') {
window.SidebarManager.updateMetadataFooter();
}
// Ensure we have the latest galleries, sidebar elements, and styles
// before constructing the customData object.
// Synchronize galleries first if the function exists
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries synchronized within saveMetadata');
}
// Retrieve current galleries (use window.galleries as the source of truth after sync)
const currentGalleries = window.galleries || galleries || [];
console.log(`Current galleries count before constructing customData: ${currentGalleries.length}`);
// Retrieve current sidebar elements
const currentSidebarElements = window.SidebarManager ? window.SidebarManager.elements : [];
// Retrieve current menu styles
const currentMenuStyles = window.MenuStyleCustomizer ? window.MenuStyleCustomizer.settings : {};
// Now construct customData using the retrieved current state
if (window.saveGalleries) {
const customData = {
galleries: currentGalleries, // Use the synchronized/retrieved galleries
siteMetadata: metadata, // Use the metadata gathered from the form
sidebarElements: currentSidebarElements, // Use the retrieved sidebar elements
menuStyles: currentMenuStyles // Use the retrieved menu styles
};
console.log(`Saving metadata with preserved galleries: ${customData.galleries.length}`);
console.log("Metadata being saved:", JSON.stringify(metadata));
console.log("Google Analytics specifically:", metadata.googleAnalytics);
console.log("Sidebar elements being saved:", customData.sidebarElements.length);
console.log("Menu styles being saved:", JSON.stringify(customData.menuStyles));
// Save with our correctly constructed custom data object
window.saveGalleries(customData)
.then(() => {
alert('Metadata saved successfully');
toggleMetadataEditor(); // Hide the editor
})
.catch(error => {
console.error('Error saving metadata:', error);
alert('Error saving metadata. Please try again.');
});
} else {
alert('Save function not available');
}
}
// Function to add new entries to the style editor
function showStyleEditor(display = false) {
// Check if style editor already exists
let styleEditor = document.getElementById('menuStyleEditor');
if (!styleEditor) {
// Get the edit controls element (to place the editor after it)
const editControls = document.querySelector('.edit-controls');
if (editControls && window.MenuStyleCustomizer) {
// Create a wrapper for the style editor
styleEditor = document.createElement('div');
styleEditor.id = 'menuStyleEditor';
styleEditor.style.display = 'none';
// Insert the editor after the edit controls
editControls.parentNode.insertBefore(styleEditor, editControls.nextSibling);
// Create the style editor UI
window.MenuStyleCustomizer.createStyleEditor(styleEditor);
}
}
// Only show the editor if display parameter is true
if (styleEditor && display) {
styleEditor.style.display = 'block';
}
}
// New function to hide the style editor
function hideStyleEditor() {
const styleEditor = document.getElementById('menuStyleEditor');
if (styleEditor) {
styleEditor.style.display = 'none';
}
}
/**
* Improved toggleAddForm - Closes other forms first and ensures proper sizing
*/
function toggleAddForm() {
const formId = 'addForm';
const form = document.getElementById(formId);
if (!form) return;
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Determine the appropriate max-height based on content
// First check if we're showing the page form by checking the radio button
const isPageSelected = document.querySelector('input[name="galleryType"][value="page"]')?.checked;
// Allow extra space for the page form
const maxHeight = isPageSelected ? '600px' : '500px';
// Show the form with proper max height
form.style.maxHeight = maxHeight;
form.style.overflow = 'visible'; // Ensure all content is visible
form.classList.add('visible');
// Also add inline styles to ensure visibility during animation
form.style.padding = '15px';
// Force recalculation of max-height after UI is visible
setTimeout(() => {
// Get actual content height + padding
const contentHeight = form.scrollHeight + 30; // Add padding
// Set max-height based on content with minimum of 500px
form.style.maxHeight = Math.max(contentHeight, 500) + 'px';
console.log(`Setting add form max height to ${form.style.maxHeight}`);
}, 50);
// Populate parent options
const parentSelect = document.getElementById('galleryParent');
if (parentSelect) {
parentSelect.innerHTML = '';
// Add all galleries that could be parents (including submenus)
galleries.forEach(gallery => {
parentSelect.innerHTML += ``;
});
}
// Set this as the current open form
window.currentOpenForm = formId;
}
// Store the currently open form ID
window.currentOpenForm = null;
/**
* Close all editor forms with improved handling for add form
*/
function closeAllForms() {
// Add Form - Special handling to ensure proper animation
const addForm = document.getElementById('addForm');
if (addForm) {
addForm.classList.remove('visible');
// Also force style reset after animation completes
setTimeout(() => {
if (!addForm.classList.contains('visible')) {
addForm.style.maxHeight = '0';
addForm.style.overflow = 'hidden';
addForm.style.padding = '0 15px';
}
}, 350); // Set slightly longer than CSS transition
}
// Style Editor
const styleEditor = document.getElementById('menuStyleEditor');
if (styleEditor) {
styleEditor.style.display = 'none';
}
// Metadata Editor
const metadataEditor = document.getElementById('metadataEditor');
if (metadataEditor) {
metadataEditor.style.display = 'none';
}
// Sidebar Element Form
const sidebarElementForm = document.getElementById('sidebarElementForm');
if (sidebarElementForm) {
sidebarElementForm.style.display = 'none';
sidebarElementForm.classList.remove('visible');
}
// Import Classic Form
const importClassicForm = document.getElementById('importClassicForm');
if (importClassicForm) {
importClassicForm.style.display = 'none';
importClassicForm.classList.remove('visible');
}
// Clear the current open form
window.currentOpenForm = null;
}
function downloadJson() {
const data = JSON.stringify(galleries, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'galleries.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
let galleriesBackup = [];
/**
* Improved version of initializeNestedSortables with a better nesting approach
*/
function initializeNestedSortables() {
console.log('Initializing nested sortables with improved nesting logic...');
// Create a backup of galleries
try {
galleriesBackup = JSON.parse(JSON.stringify(galleries));
} catch (error) {
console.error('Error creating galleries backup:', error);
galleriesBackup = [];
}
// Find all nested sortable lists
const nestedSortables = document.querySelectorAll('.nested-sortable');
if (nestedSortables.length === 0) {
console.warn('No nested sortable elements found! Check your HTML structure.');
return;
}
// Helper: compute depth based on ancestor nested lists
function computeDepth(liEl) {
let depth = 0;
let node = liEl;
while (node) {
const parentOl = node.closest('ol.nested-sortable');
if (!parentOl) break;
const parentLi = parentOl.closest('li');
if (parentLi) {
depth += 1;
node = parentLi;
} else {
break;
}
}
// Top-level depth should be 0
return Math.max(depth - 1, 0);
}
// Helper: apply indentation styling to an li and its subtree
function applyIndentationFromDepth(rootLi) {
if (!rootLi) return;
const applyToItem = (li) => {
const depth = computeDepth(li);
// Update data-nesting-level attribute
li.setAttribute('data-nesting-level', depth);
// Apply padding-left style
const content = li.querySelector('.gallery-item-content');
if (content) {
content.style.paddingLeft = (depth * 16) + 'px';
}
};
applyToItem(rootLi);
rootLi.querySelectorAll('li').forEach(applyToItem);
}
// Helper: ensure parent has proper state and nested list
function ensureParentChildState(parentLi) {
if (!parentLi) return null;
parentLi.classList.add('has-children');
let nestedList = parentLi.querySelector('ol.nested-sortable');
if (!nestedList) {
nestedList = document.createElement('ol');
nestedList.className = 'nested-sortable';
parentLi.appendChild(nestedList);
}
return nestedList;
}
// Helper: clean parent if it has no children
function cleanupParentIfEmpty(parentLi) {
if (!parentLi) return;
const nestedList = parentLi.querySelector('ol.nested-sortable');
if (nestedList && nestedList.children.length === 0) {
nestedList.remove();
parentLi.classList.remove('has-children');
}
}
// Initialize each sortable with improved nesting settings
let sortableInstances = [];
for (let i = 0; i < nestedSortables.length; i++) {
try {
const container = nestedSortables[i];
// Track where the placeholder indicates the item should be placed
// This helps us detect when SortableJS incorrectly auto-nests based on mouse position
let intendedPlacementContainer = null;
let intendedRelatedElement = null;
// Track last move to prevent oscillation
let lastMoveTime = 0;
let lastMoveRelatedId = null;
const MOVE_DEBOUNCE_MS = 50; // Minimum time between moves (in milliseconds)
// Track if we're dragging the last item in a folder (to prevent boundary oscillation)
let isLastItemInFolder = false;
let draggedItemOriginalContainer = null;
// Track container changes to prevent rapid switching (hysteresis)
let firstPositionFixLoopActive = false; // Prevent multiple fix loops
let lastContainerDecision = null; // 'inside' or 'outside'
let lastContainerDecisionTime = 0;
const CONTAINER_SWITCH_DEBOUNCE_MS = 150; // Longer debounce for container switches
const sortable = new Sortable(container, {
group: 'nested',
animation: 150, // Reduced animation during drag to reduce lag
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', // Smooth easing function
fallbackOnBody: false, // Disable forced fallback on desktop for better performance
forceFallback: false, // Use native drag on desktop
// CRITICAL: These settings make nesting much easier
// Higher swap threshold makes it easier to drag over items
swapThreshold: 0.65, // Increased to reduce oscillation within same folder
// IMPROVED NESTING SETTINGS
// Used when determining if an item should be nested
invertSwap: false, // Disable to reduce oscillation
direction: 'vertical', // Lock direction to vertical to reduce oscillation
// Use handle for dragging
handle: '.nested-sortable-handle',
// Distinguish between click and drag
delay: 120,
delayOnTouchOnly: true,
// Empty container settings
emptyInsertThreshold: 10,
// Add custom ghost class
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
// Make sure we listen to external move events
dragoverBubble: true,
// Store original dimensions to prevent layout shifts
preventOnFilter: false,
// Better drag preview stability
scroll: true,
scrollSensitivity: 100,
scrollSpeed: 20,
// Reduce oscillation by making placeholder movement less sensitive
forceFallback: false, // Use native drag for better performance
fallbackTolerance: 0, // No tolerance - use exact position
/**
* NESTING LOGIC: Prevent auto-nesting based on hover
* Instead, rely on SortableJS's placeholder position which is accurate
* We'll verify and correct placement in onEnd based on actual DOM position
*/
onMove: function(evt, originalEvent) {
const dragged = evt.dragged;
const related = evt.related;
if (!dragged || !related) return true;
// Safety check: Ensure items have data-id attributes
// This can be missing if item was just added and DOM not fully updated
if (!dragged.dataset.id || !related.dataset.id) {
console.warn('⚠️ [Drag] Item missing data-id attribute, aborting drag', {
draggedHasId: !!dragged.dataset.id,
relatedHasId: !!related.dataset.id
});
return false;
}
// Get the IDs to check for circular references
const draggedId = parseInt(dragged.dataset.id);
const relatedId = parseInt(related.dataset.id);
// Don't allow dropping inside itself or its descendants
if (draggedId === relatedId || hasAncestor(relatedId, draggedId)) {
return false;
}
const currentTime = Date.now();
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
const draggedParent = draggedItemOriginalContainer || dragged.parentElement;
// Determine if we're moving inside or outside a folder
// IMPORTANT: Check if related element IS the folder item itself (parent li)
// When targeting first position, SortableJS might use the folder item as related
const isRelatedFolderItem = related.classList && related.classList.contains('has-children');
let actualRelatedParent = related.parentElement;
// If related IS the folder item, we need to get its nested sortable container
if (isRelatedFolderItem) {
const nestedSortable = related.querySelector('ol.nested-sortable');
if (nestedSortable) {
actualRelatedParent = nestedSortable;
}
}
const relatedParent = actualRelatedParent;
const isMovingInsideFolder = relatedParent &&
relatedParent.classList.contains('nested-sortable') &&
relatedParent !== rootContainer;
const isMovingOutsideFolder = !isRelatedFolderItem && relatedParent === rootContainer;
const currentDecision = isMovingInsideFolder ? 'inside' : (isMovingOutsideFolder ? 'outside' : null);
// Determine if we're moving within the same folder - this is the key check
// If related IS the folder item, we're still moving within the same folder
let isMovingWithinSameFolder = (isMovingInsideFolder &&
draggedParent &&
draggedParent.classList.contains('nested-sortable') &&
draggedParent !== rootContainer &&
relatedParent === draggedParent) ||
// OR: if related is the folder item itself and we're dragging from that folder
(isRelatedFolderItem &&
draggedParent &&
draggedParent.classList.contains('nested-sortable') &&
draggedParent !== rootContainer &&
related.querySelector('ol.nested-sortable') === draggedParent);
// Special case: If related is the folder item, we're definitely trying to move within that folder
// This happens when targeting first position - SortableJS uses the folder item as related
if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderNestedSortable = related.querySelector('ol.nested-sortable');
if (folderNestedSortable === draggedParent) {
// We're dragging within the folder, and related is the folder item
// This means we're targeting first position - FORCE it to be treated as same-folder move
// FORCE same-folder detection - override any previous calculation
isMovingWithinSameFolder = true;
}
}
// DEBUG: Log placeholder position changes ONLY when dragging from within a folder AND something interesting happens
// (Don't log every single move - too noisy)
if (draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItems = Array.from(draggedParent.children);
const draggedIndex = folderItems.indexOf(dragged);
const relatedIndex = folderItems.indexOf(related);
// Get placeholder element to see where it actually is
const placeholder = document.querySelector('.sortable-ghost');
const placeholderParent = placeholder ? placeholder.parentElement : null;
const placeholderIndex = placeholderParent && placeholderParent.classList.contains('nested-sortable')
? Array.from(placeholderParent.children).indexOf(placeholder)
: -1;
const folderItem = draggedParent.closest('li.has-children');
const folderItemId = folderItem ? folderItem.dataset.id : null;
const firstItemInFolder = folderItems.length > 0 ? folderItems[0] : null;
const firstItemInFolderId = firstItemInFolder ? firstItemInFolder.dataset.id : null;
// Check if we're trying to target first position
// related could be the first item OR the folder item itself (when SortableJS uses folder as related)
const isFirstPosition = folderItems.length > 0 && related === firstItemInFolder;
const isRelatedFolderItemCheck = related === folderItem;
const isTargetingFirstViaFolderItem = isRelatedFolderItemCheck && !!folderItem; // FIXED: was assigning folderItem instead of boolean
// When targeting first via folder item, placeholder should be at index 0
// But we're seeing it at index 1 - this is the issue!
const shouldBeAtFirstPosition = isTargetingFirstViaFolderItem && placeholderIndex === 1 && draggedIndex > 0;
// Check if placeholder is outside the folder when it should be inside
const placeholderIsOutside = placeholderParent === rootContainer || (placeholderParent && !placeholderParent.classList.contains('nested-sortable'));
const shouldBeInside = draggedIndex >= 0; // We're dragging from inside
// Only log when there's a potential issue or interesting state change
const placeholderMismatch = placeholderIndex !== relatedIndex && placeholderIndex >= 0 && relatedIndex >= 0;
const jumpingOut = !isMovingWithinSameFolder && draggedIndex >= 0 && relatedIndex < 0;
const jumpingOutToRoot = placeholderIsOutside && shouldBeInside;
// First position issue: trying to target first but:
// - placeholder is outside OR
// - placeholder is at index 1 instead of 0 (when targeting first via folder item)
const firstPositionIssue = (isFirstPosition || isTargetingFirstViaFolderItem) && (jumpingOut || jumpingOutToRoot || placeholderIsOutside || shouldBeAtFirstPosition);
// ALWAYS log when targeting first position via folder item (this is the key case we're debugging)
// OR when placeholder jumps out when it should stay in
// OR when placeholder should be at first position but isn't
const shouldLog = placeholderMismatch || jumpingOut || jumpingOutToRoot || firstPositionIssue || isTargetingFirstViaFolderItem || shouldBeAtFirstPosition;
if (shouldLog) {
console.error('🔍 [Placeholder Position]', {
draggedId,
draggedIndex,
relatedId,
relatedIndex,
placeholderIndex,
relatedElementTag: related.tagName,
isRelatedFolderItem: isRelatedFolderItemCheck,
isTargetingFirstViaFolderItem,
isMovingWithinSameFolder,
placeholderMismatch,
jumpingOut,
jumpingOutToRoot,
isFirstPosition,
placeholderIsOutside,
placeholderParentType: placeholderParent === rootContainer ? 'ROOT' : (placeholderParent && placeholderParent.classList.contains('nested-sortable') ? 'FOLDER' : 'OTHER'),
folderItemId,
firstItemInFolderId,
firstPositionIssue,
shouldBeAtFirstPosition,
expectedPlaceholderIndex: isTargetingFirstViaFolderItem ? 0 : placeholderIndex
});
}
}
// CRITICAL: For moves within the same folder, skip ALL the complex boundary logic
// This allows smooth reordering just like items not in folders
if (isMovingWithinSameFolder) {
// Simple debounce only - same as root level items
const timeSinceLastMove = currentTime - lastMoveTime;
// Only prevent very rapid oscillation to same element
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5 && relatedId === lastMoveRelatedId) {
return false;
}
// Allow all moves within same folder - update tracking and continue
// SPECIAL FIX: When targeting first position via folder item, fix placeholder position
// SortableJS places it at index 1 when related is the folder item, but we want it at index 0
// BUT: Only apply this fix when actually near/at first position to avoid being too aggressive
if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItems = Array.from(draggedParent.children);
const firstItem = folderItems.length > 0 ? folderItems[0] : null;
// Only apply fix if we're actually near first position (draggedIndex is small, indicating we're moving up)
// This prevents the fix from being too aggressive when moving to other positions
const isNearFirstPosition = draggedIndex <= 2; // Allow fix when within first 3 positions
if (firstItem && firstItem !== dragged && isNearFirstPosition) {
// Fix immediately (synchronously) first - don't wait for next frame
const placeholder = document.querySelector('.sortable-ghost');
if (placeholder && placeholder.parentElement === draggedParent) {
const currentIndex = Array.from(draggedParent.children).indexOf(placeholder);
// Only fix if placeholder is at index 1 when we're targeting first position
// Don't force it if it's at index 0 or beyond index 2 (user might be moving to position 2 or 3)
if (currentIndex === 1 && draggedIndex <= 1) {
draggedParent.insertBefore(placeholder, firstItem);
}
}
} else {
// Not near first position - stop any active fix loop
firstPositionFixLoopActive = false;
}
} else {
// Not targeting first position anymore - reset flag
firstPositionFixLoopActive = false;
}
lastMoveTime = currentTime;
lastMoveRelatedId = relatedId;
// Skip all the boundary case logic below - just continue to allow the move
// Fall through to update intendedPlacementContainer and return true
} else {
// Only apply complex boundary logic for moves BETWEEN folders or to/from root
// Hysteresis: Prevent rapid switching between inside/outside folder
const targetFolderItems = isMovingInsideFolder ? Array.from(relatedParent.children) : [];
const isFirstPositionInFolder = isMovingInsideFolder &&
targetFolderItems.length > 0 &&
related === targetFolderItems[0];
if (currentDecision && lastContainerDecision && currentDecision !== lastContainerDecision) {
const timeSinceContainerSwitch = currentTime - lastContainerDecisionTime;
// Make first position easier to target from outside
if (isFirstPositionInFolder) {
// Moving to first position from outside - use shorter debounce
const effectiveDebounce = CONTAINER_SWITCH_DEBOUNCE_MS * 0.3;
if (timeSinceContainerSwitch < effectiveDebounce) {
// Only block if it's very rapid (oscillation)
if (timeSinceContainerSwitch < MOVE_DEBOUNCE_MS) {
return false;
}
}
} else {
// Normal container switch - apply full hysteresis
if (timeSinceContainerSwitch < CONTAINER_SWITCH_DEBOUNCE_MS) {
return false;
}
}
}
}
// Special handling for boundary cases (last item in folder, or dragging into folder)
// BUT: Skip all this logic if we're moving within the same folder (already handled above)
if (!isMovingWithinSameFolder && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) {
const folderItem = draggedParent.closest('li.has-children');
const currentFolderItems = Array.from(draggedParent.children);
const isFirstPositionInCurrentFolder = currentFolderItems.length > 0 &&
currentFolderItems[0] !== dragged &&
(related === currentFolderItems[0] ||
(isMovingInsideFolder && relatedParent === draggedParent &&
related === currentFolderItems[0]));
// Case 0: This case is now handled earlier for same-folder moves
// Only handle cross-folder moves here
// Case 1: Dragging last item out of folder
if (isLastItemInFolder && isMovingOutsideFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// BUT: If we're trying to move to first position, don't block it
// Check if the related element is the folder itself or right after it
if (folderItem) {
const folderNextSibling = folderItem.nextElementSibling;
// If we're moving to position right after folder, it might be trying to go to first position
// Allow it if it's been a reasonable time
if (folderNextSibling && related === folderNextSibling) {
// This is ambiguous - could be first position or outside
// Require more time but not as much as pure outside move
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
} else {
// Definitely moving outside - require longer debounce
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) {
if (relatedId === lastMoveRelatedId) {
return false; // Reject if oscillating
}
}
}
} else {
// No folder item found - apply standard debounce
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 2: Dragging item into folder from outside
if (isMovingInsideFolder && relatedParent !== draggedParent) {
const targetFolderItems = Array.from(relatedParent.children);
const isNearBottom = targetFolderItems.length > 0 &&
(related === targetFolderItems[targetFolderItems.length - 1] ||
targetFolderItems.indexOf(related) >= targetFolderItems.length - 1);
const isFirstPos = targetFolderItems.length > 0 && related === targetFolderItems[0];
// Moving to first position - make it easy
if (isFirstPos) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Very short debounce for first position
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.3 && relatedId === lastMoveRelatedId) {
return false;
}
// Allow move to first position
} else if (isNearBottom) {
// For bottom position, still use some debounce to prevent oscillation
const timeSinceLastMove = currentTime - lastMoveTime;
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 2.5: Prevent jumping OUT of folder when trying to move to first/last position within
// This is tricky - SortableJS might detect it as outside when we're actually targeting first/last
if (isMovingOutsideFolder && folderItem && draggedParent === draggedItemOriginalContainer) {
// We're dragging from within a folder but detected as moving outside
// Check if the related element is adjacent to the folder (which might indicate targeting first/last)
const folderIndex = Array.from(rootContainer.children).indexOf(folderItem);
const relatedIndex = Array.from(rootContainer.children).indexOf(related);
// If related is immediately before or after folder, might be targeting first/last
if (folderIndex >= 0 && relatedIndex >= 0) {
const isAdjacentToFolder = (relatedIndex === folderIndex - 1) || (relatedIndex === folderIndex + 1);
if (isAdjacentToFolder) {
// Might be targeting first/last position - be lenient
const timeSinceLastMove = currentTime - lastMoveTime;
// Only block if it's very rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.4 && relatedId === lastMoveRelatedId) {
return false;
}
// Otherwise allow - user might be trying to target first/last position
// Don't block based on this check
}
}
}
}
// Case 3: Dragging from root into folder
if (draggedParent === rootContainer && isMovingInsideFolder) {
const targetFolderItems = Array.from(relatedParent.children);
const isNearBottom = targetFolderItems.length > 0 &&
(related === targetFolderItems[targetFolderItems.length - 1] ||
targetFolderItems.indexOf(related) >= targetFolderItems.length - 1);
const isFirstPosition = targetFolderItems.length > 0 && related === targetFolderItems[0];
// Special handling for first position - make it easier to target
if (isFirstPosition) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Make first position easier - only prevent very rapid oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
// Allow move to first position - don't block it
// Continue to track but don't apply strict debouncing
} else if (isNearBottom) {
// Keep strict debouncing for bottom position
const timeSinceLastMove = currentTime - lastMoveTime;
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) {
if (relatedId === lastMoveRelatedId) {
return false;
}
}
}
}
// Case 4: Prevent jumping FROM first position to outside folder (but allow easy access TO first position)
// If we're at first position and trying to move outside, require deliberate movement
if (isMovingInsideFolder) {
const currentFolderItems = Array.from(relatedParent.children);
const isFirstPos = currentFolderItems.length > 0 && related === currentFolderItems[0];
// If we're moving FROM first position to outside (reverse hysteresis)
if (isFirstPos && lastContainerDecision === 'outside') {
const timeSinceLastMove = currentTime - lastMoveTime;
// If we just moved outside, require more time before allowing back to first position
// BUT: if it's been a reasonable time, allow it (don't make it too hard)
if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.8 && relatedId === lastMoveRelatedId) {
// Very rapid - might be oscillation
return false;
}
}
}
// Case 5: Prevent oscillation between first and second position within folder
// BUT: Only apply if NOT moving within same folder (to allow smooth reordering)
if (isMovingInsideFolder && relatedParent && !isMovingWithinSameFolder) {
const folderItems = Array.from(relatedParent.children);
const isFirstPos = folderItems.length > 0 && related === folderItems[0];
const isSecondPos = folderItems.length > 1 && related === folderItems[1];
// If moving from first to second (or vice versa) very rapidly, prevent oscillation
if ((isFirstPos || isSecondPos) && lastMoveRelatedId) {
const lastRelated = document.querySelector('li[data-id="' + lastMoveRelatedId + '"]');
if (lastRelated && lastRelated.parentElement === relatedParent) {
const lastRelatedIndex = folderItems.indexOf(lastRelated);
const isOscillating = (isFirstPos && lastRelatedIndex === 1) || (isSecondPos && lastRelatedIndex === 0);
if (isOscillating) {
const timeSinceLastMove = currentTime - lastMoveTime;
// Allow moving to first position easily, but prevent rapid oscillation between first/second
if (isSecondPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.2) {
return false; // Prevent jumping from first to second too easily
}
// But allow first position more easily
if (isFirstPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.6 && relatedId === lastMoveRelatedId) {
return false; // Only prevent very rapid oscillation
}
}
}
}
}
// Prevent rapid oscillation when dragging within same container
// NOTE: For same-folder moves, we already updated tracking above, so skip this section
if (!isMovingWithinSameFolder) {
const timeSinceLastMove = currentTime - lastMoveTime;
// If moving to the same related element very quickly, reject to prevent oscillation
if (timeSinceLastMove < MOVE_DEBOUNCE_MS && relatedId === lastMoveRelatedId) {
return false;
}
// Update tracking variables (only for non-same-folder moves)
lastMoveTime = currentTime;
lastMoveRelatedId = relatedId;
}
// For same-folder moves, tracking was already updated in the earlier block
// Update container decision tracking
if (currentDecision) {
if (currentDecision !== lastContainerDecision) {
lastContainerDecisionTime = currentTime;
}
lastContainerDecision = currentDecision;
}
// Track where the placeholder indicates the item should be placed
// The related element is next to where the placeholder will be positioned
// Check if it's at root level or nested level
if (related) {
intendedRelatedElement = related;
const relatedParent = related.parentElement;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
// Determine if the placeholder is at root level or nested level
if (relatedParent === rootContainer) {
// Placeholder is at root level (not nested)
intendedPlacementContainer = rootContainer;
} else if (relatedParent && relatedParent.classList.contains('nested-sortable')) {
// Placeholder is in a nested list (inside a folder)
intendedPlacementContainer = relatedParent;
}
// Update ghost element indentation to match the target position
requestAnimationFrame(() => {
const ghost = document.querySelector('.sortable-ghost');
if (ghost && intendedPlacementContainer) {
const ghostContent = ghost.querySelector('.gallery-item-content');
if (ghostContent) {
// Calculate the depth where the ghost will be placed
// Use the related element to determine the depth (if it exists and is in the target container)
let targetDepth = 0;
if (intendedPlacementContainer === rootContainer) {
targetDepth = 0; // Root level
} else if (related && related.parentElement === intendedPlacementContainer) {
// Use the related element's depth to determine target depth
targetDepth = computeDepth(related);
} else {
// Fallback: find first child in the container or calculate from parent folder
const firstChild = intendedPlacementContainer.querySelector('li');
if (firstChild) {
targetDepth = computeDepth(firstChild);
} else {
const parentFolder = intendedPlacementContainer.closest('li.has-children');
if (parentFolder) {
targetDepth = computeDepth(parentFolder) + 1;
}
}
}
ghostContent.style.paddingLeft = (targetDepth * 16) + 'px';
}
}
});
}
// Let SortableJS handle the placement based on placeholder position
// We'll verify and correct if needed in onEnd
return true; // Allow the move
},
// Capture indentation before drag starts (fires when item is chosen)
onChoose: function(evt) {
if (!evt.item) return;
const draggedItem = evt.item;
const content = draggedItem.querySelector('.gallery-item-content');
// Store the original padding-left for the ghost
if (content) {
const computedStyle = window.getComputedStyle(content);
const originalPaddingLeft = computedStyle.paddingLeft;
draggedItem.dataset.originalPaddingLeft = originalPaddingLeft;
}
},
// Visual feedback on drag start
onStart: function(evt) {
if (!evt.item) return;
// Don't set dimensions on the dragged item - let it maintain natural size
// Setting width/height can cause layout shifts and menu expansion
const draggedItem = evt.item;
// Store original container and check if it's the last item in folder
draggedItemOriginalContainer = draggedItem.parentElement;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (draggedItemOriginalContainer &&
draggedItemOriginalContainer.classList.contains('nested-sortable') &&
draggedItemOriginalContainer !== rootContainer) {
const siblings = Array.from(draggedItemOriginalContainer.children);
isLastItemInFolder = siblings.length > 0 && siblings[siblings.length - 1] === draggedItem;
} else {
isLastItemInFolder = false;
}
// Apply the stored padding-left to the ghost element
// The ghost is created by SortableJS at this point
requestAnimationFrame(() => {
const ghost = document.querySelector('.sortable-ghost');
if (ghost) {
const ghostContent = ghost.querySelector('.gallery-item-content');
const originalPaddingLeft = draggedItem.dataset.originalPaddingLeft;
if (ghostContent && originalPaddingLeft) {
ghostContent.style.paddingLeft = originalPaddingLeft;
}
}
});
// Reset oscillation tracking variables
lastMoveTime = 0;
lastMoveRelatedId = null;
lastContainerDecision = null;
lastContainerDecisionTime = 0;
// Create a backup
try {
window._dragStartElementIds = [];
document.querySelectorAll('.nested-sortable li').forEach(li => {
if (li.dataset.id) {
window._dragStartElementIds.push(li.dataset.id);
}
});
galleriesBackup = JSON.parse(JSON.stringify(galleries));
} catch (error) {
console.error('Error creating backup:', error);
}
// Add indicator classes
document.body.classList.add('menu-item-dragging');
// Add hover indicator class to submenu items
document.querySelectorAll('.has-children').forEach(item => {
item.classList.add('potential-parent');
});
// Prevent layout recalculation during drag
document.body.style.setProperty('--is-dragging', '1');
},
// Cleanup on end
onEnd: function(evt) {
// Verify and correct placement based on placeholder's intended position
// SortableJS may auto-nest incorrectly based on mouse position, even if placeholder shows root level
const dragged = evt.item;
const previousParentLi = evt.from ? evt.from.closest('li') : null;
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (!dragged || !rootContainer) {
intendedPlacementContainer = null;
intendedRelatedElement = null;
// Restore normal sizing
dragged.style.width = '';
dragged.style.height = '';
dragged.style.minHeight = '';
document.body.style.removeProperty('--is-dragging');
return;
}
// Get the actual parent container where SortableJS placed the item
const actualParentList = dragged.parentElement;
// Restore normal sizing - use requestAnimationFrame to prevent layout thrashing
requestAnimationFrame(() => {
dragged.style.width = '';
dragged.style.height = '';
dragged.style.minHeight = '';
document.body.style.removeProperty('--is-dragging');
});
// Check if the intended placement (where placeholder showed) differs from actual placement
// This happens when SortableJS auto-nests based on mouse position over a folder
if (intendedPlacementContainer && actualParentList !== intendedPlacementContainer) {
// The item ended up in a different container than the placeholder indicated
// Move it to where the placeholder showed it should be (next to intendedRelatedElement)
if (intendedPlacementContainer === rootContainer) {
// Placeholder was at root level - move item to root, positioned next to related element
if (intendedRelatedElement && intendedRelatedElement.parentElement === rootContainer) {
// Insert before the related element (where placeholder was)
rootContainer.insertBefore(dragged, intendedRelatedElement);
console.log("Corrected placement: moved from folder to root level where placeholder indicated");
} else if (intendedRelatedElement) {
// Related element exists but not at root - find its folder and insert before it at root
const folderItem = intendedRelatedElement.closest('li.has-children');
if (folderItem && folderItem.parentElement === rootContainer) {
rootContainer.insertBefore(dragged, folderItem);
console.log("Corrected placement: moved to root level above folder where placeholder indicated");
} else {
rootContainer.appendChild(dragged);
}
} else {
rootContainer.appendChild(dragged);
}
// Update indentation since it's now at root
applyIndentationFromDepth(dragged);
} else {
// Placeholder was in a nested list - move it there
if (intendedRelatedElement && intendedRelatedElement.parentElement === intendedPlacementContainer) {
// Insert before the related element in the nested list
intendedPlacementContainer.insertBefore(dragged, intendedRelatedElement);
} else {
intendedPlacementContainer.appendChild(dragged);
}
console.log("Corrected placement: moved to nested list where placeholder indicated");
// Update indentation for nested item
applyIndentationFromDepth(dragged);
// Ensure parent folder has proper structure
const folderItem = intendedPlacementContainer.closest('li.has-children');
if (folderItem) {
const nestedList = folderItem.querySelector('ol.nested-sortable');
if (nestedList && intendedPlacementContainer !== nestedList) {
nestedList.appendChild(dragged);
}
const parentDepth = computeDepth(folderItem);
const nestedDepth = parentDepth + 1;
if (nestedList) {
nestedList.className = 'nested-sortable nested-level-' + nestedDepth;
}
folderItem.classList.add('has-children');
}
}
} else if (actualParentList && actualParentList.classList.contains('nested-sortable') && actualParentList !== rootContainer) {
// Item is correctly in a nested list - ensure proper structure
const folderItem = actualParentList.closest('li.has-children');
if (folderItem) {
const nestedList = folderItem.querySelector('ol.nested-sortable');
if (nestedList && actualParentList !== nestedList) {
nestedList.appendChild(dragged);
}
const parentDepth = computeDepth(folderItem);
const nestedDepth = parentDepth + 1;
if (nestedList) {
nestedList.className = 'nested-sortable nested-level-' + nestedDepth;
}
folderItem.classList.add('has-children');
}
applyIndentationFromDepth(dragged);
} else {
// Item is correctly at root level
applyIndentationFromDepth(dragged);
}
// Reset tracking variables
intendedPlacementContainer = null;
intendedRelatedElement = null;
isLastItemInFolder = false;
draggedItemOriginalContainer = null;
lastContainerDecision = null;
lastContainerDecisionTime = 0;
// Apply indentation updates for ALL affected items after drag
// Use requestAnimationFrame to batch DOM updates and prevent visual jumps
requestAnimationFrame(() => {
if (dragged) {
// Update the dragged item and all its descendants
applyIndentationFromDepth(dragged);
// Update all items in the new container (siblings of dragged item)
const newContainer = dragged.parentElement;
if (newContainer && newContainer.classList.contains('nested-sortable')) {
const siblings = Array.from(newContainer.children);
siblings.forEach(sibling => {
if (sibling !== dragged && sibling.tagName === 'LI') {
applyIndentationFromDepth(sibling);
}
});
}
// Update all items in the old container (if it still exists and has items)
if (evt.from && evt.from.classList.contains('nested-sortable')) {
const oldSiblings = Array.from(evt.from.children);
oldSiblings.forEach(sibling => {
if (sibling.tagName === 'LI') {
applyIndentationFromDepth(sibling);
}
});
}
// Also update all items in the entire tree to ensure consistency
// This handles edge cases where indentation might be off
const allItems = document.querySelectorAll('#galleryTree .nested-sortable li');
allItems.forEach(li => {
const depth = computeDepth(li);
li.setAttribute('data-nesting-level', depth);
const content = li.querySelector('.gallery-item-content');
if (content) {
content.style.paddingLeft = (depth * 16) + 'px';
}
});
}
});
// Clean up previous parent if it lost its last child
if (previousParentLi) {
cleanupParentIfEmpty(previousParentLi);
}
// Remove all indicator classes
document.body.classList.remove('menu-item-dragging');
document.querySelectorAll('.potential-parent, .will-accept-child, .will-be-nested').forEach(el => {
el.classList.remove('potential-parent', 'will-accept-child', 'will-be-nested');
});
// Standard cleanup for ghost elements
document.querySelectorAll('.sortable-ghost, .sortable-chosen, .sortable-drag').forEach(item => {
item.classList.remove('sortable-ghost', 'sortable-chosen', 'sortable-drag');
});
// Check if drop was outside a valid container
if (!evt.to || !evt.to.classList.contains('nested-sortable')) {
console.warn('Item was dropped outside a valid container - reverting');
restoreFromBackup();
return;
}
// Verify no elements were lost
try {
// Check DOM elements
const endElementIds = [];
document.querySelectorAll('.nested-sortable li').forEach(li => {
if (li.dataset.id) {
endElementIds.push(li.dataset.id);
}
});
if (window._dragStartElementIds && window._dragStartElementIds.length > 0) {
const startCount = window._dragStartElementIds.length;
const endCount = endElementIds.length;
if (endCount < startCount) {
console.error(`DOM elements were lost during drag! Original: ${startCount}, New: ${endCount}`);
restoreFromBackup();
return;
}
}
// Update the data structure
updateGalleryStructure();
// Verify data model
const originalCount = countGalleries(galleriesBackup);
const newCount = countGalleries(galleries);
if (newCount < originalCount) {
console.error(`Items were lost! Original: ${originalCount}, New: ${newCount}`);
restoreFromBackup();
return;
}
// Save changes
if (window.saveGalleries) {
window.saveGalleries().then(() => {
console.log('Gallery structure saved successfully after drag');
}).catch(error => {
console.error('Error saving gallery structure after drag:', error);
});
}
} catch (error) {
console.error('Error updating gallery structure:', error);
restoreFromBackup();
}
}
});
sortableInstances.push(sortable);
} catch (error) {
console.error(`Error initializing sortable for container ${i}:`, error);
}
}
return sortableInstances;
}
// NEW HELPER FUNCTION: Gets the nesting level of an element
function getElementNestingLevel(element) {
let level = 0;
let current = element;
// Count how many nested-sortable parents this element has
while (current && current.parentElement) {
if (current.parentElement.classList && current.parentElement.classList.contains('nested-sortable')) {
level++;
}
current = current.parentElement;
}
return level;
}
// NEW HELPER FUNCTION: Determines if an item would become nested
function wouldBeNestedItem(draggedEl, relatedEl, event) {
if (!relatedEl || !draggedEl) return false;
// Get mouse position
const mouseX = event.clientX;
// Get the bounding rect of the related element
const rect = relatedEl.getBoundingClientRect();
// Determine the nesting threshold - how far from the left edge
// the mouse needs to be to consider it a nesting operation
const nestingThreshold = 25; // pixels from left edge
// Check if mouse is within the nesting threshold from the left edge
const distanceFromLeft = mouseX - rect.left;
// Check if we should nest (mouse is within threshold of left edge)
const wouldNest = distanceFromLeft <= nestingThreshold;
// Log the calculation for debugging
console.log(`Nesting check: distance=${distanceFromLeft}px, threshold=${nestingThreshold}px, would nest=${wouldNest}`);
return wouldNest;
}
/**
* Helper function to find gallery by ID in a tree structure
* @param {Array} galleries - The gallery array to search
* @param {number} id - The ID to find
* @returns {Object|null} - The found gallery or null
*/
function findGalleryById(galleries, id) {
if (!Array.isArray(galleries)) return null;
// First check at the current level
const directMatch = galleries.find(g => g.id == id);
if (directMatch) return directMatch;
// Then check in children
for (const gallery of galleries) {
if (gallery.children && gallery.children.length > 0) {
const childMatch = findGalleryById(gallery.children, id);
if (childMatch) return childMatch;
}
}
return null;
}
/**
* Get all gallery IDs from a tree structure
* @param {Array} galleries - Array of galleries
* @returns {Array} - Flat array of all IDs
*/
function getAllGalleryIds(galleries) {
if (!Array.isArray(galleries)) return [];
let ids = [];
galleries.forEach(gallery => {
if (gallery && gallery.id) {
ids.push(gallery.id);
}
// Add child IDs if any
if (gallery.children && Array.isArray(gallery.children)) {
ids = ids.concat(getAllGalleryIds(gallery.children));
}
});
return ids;
}
/**
* Enhanced count function that handles edge cases
* @param {Array} galleryArray - The array to count
* @returns {number} - The total count of galleries
*/
function countGalleries(galleryArray) {
if (!Array.isArray(galleryArray)) return 0;
return galleryArray.reduce((count, gallery) => {
if (!gallery) return count; // Skip null/undefined items
// Count this gallery
let total = 1;
// If it has children, count them too (recursively)
if (gallery.children && Array.isArray(gallery.children)) {
total += countGalleries(gallery.children);
}
return count + total;
}, 0);
}
/**
* Enhanced backup restoration function with better debugging and notification
*/
function restoreFromBackup() {
console.warn('Restoring galleries from backup due to invalid operation');
// Verify we have a valid backup
if (!galleriesBackup || !Array.isArray(galleriesBackup) || galleriesBackup.length === 0) {
console.error('No valid backup available for restoration');
// Create a backup from the current DOM as a last resort
try {
console.log('Attempting to rebuild from DOM structure...');
rebuildGalleriesFromDOM();
return;
} catch (rebuildError) {
console.error('Failed to rebuild from DOM:', rebuildError);
// Continue with normal user notification
}
} else {
// Restore from backup
galleries = JSON.parse(JSON.stringify(galleriesBackup));
}
// Re-render the tree with the restored data
renderGalleries();
// Force reinitialize nested sortables after restoration
setTimeout(() => {
initializeNestedSortables();
}, 300);
}
/**
* Emergency recovery function that tries to rebuild galleries from DOM
* This is a last resort when backup restoration fails
*/
function rebuildGalleriesFromDOM() {
console.log('EMERGENCY RECOVERY: Attempting to rebuild galleries from DOM structure');
// Create a new galleries array
const rebuiltGalleries = [];
// This will track items we've already processed
const processedIds = new Set();
// Function to process a container and its children
function processContainer(container, parentId = null) {
// Get all immediate list items
const items = container.querySelectorAll(':scope > li');
items.forEach(item => {
// Get item ID from data attribute
const id = parseInt(item.dataset.id);
if (isNaN(id)) return; // Skip if no valid ID
// Skip already processed items to avoid duplicates
if (processedIds.has(id)) return;
processedIds.add(id);
// Try to find the original gallery data
let galleryData = null;
// First check current galleries array
const existingGallery = findGalleryById(galleries, id);
if (existingGallery) {
galleryData = { ...existingGallery };
delete galleryData.children; // We'll rebuild the hierarchy
}
// If not found, check if we have a backup
else if (galleriesBackup && Array.isArray(galleriesBackup)) {
const backupGallery = findGalleryById(galleriesBackup, id);
if (backupGallery) {
galleryData = { ...backupGallery };
delete galleryData.children; // We'll rebuild the hierarchy
}
}
// If we still don't have data, create minimal placeholder
if (!galleryData) {
// Try to extract title from DOM
const titleElement = item.querySelector('.menu-item');
const title = titleElement ? titleElement.textContent.trim() : `Item ${id}`;
galleryData = {
id: id,
title: title,
visible: !item.classList.contains('hidden-gallery')
};
}
// Update parent ID to match DOM structure
galleryData.parentId = parentId;
// Add to rebuilt galleries
rebuiltGalleries.push(galleryData);
// Process children if any
const childContainer = item.querySelector('ol.nested-sortable');
if (childContainer) {
processContainer(childContainer, id);
}
});
}
// Start with root container
const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable');
if (rootContainer) {
processContainer(rootContainer);
// Replace global galleries array with our rebuilt one
galleries = rebuiltGalleries;
// Re-render with the rebuilt data
renderGalleries();
console.log(`RECOVERY COMPLETE: Rebuilt ${rebuiltGalleries.length} items from DOM`);
return true;
} else {
console.error('RECOVERY FAILED: Root container not found');
return false;
}
}
// Make sure these functions are available to the global scope
window.initializeNestedSortables = initializeNestedSortables;
window.restoreFromBackup = restoreFromBackup;
window.getElementNestingLevel = getElementNestingLevel;
window.wouldBeNestedItem = wouldBeNestedItem;
function hasDescendant(itemId, possibleDescendantId) {
// Check if possibleDescendantId is a descendant of itemId
const children = galleries.filter(g => g.parentId === itemId);
if (children.some(child => child.id === possibleDescendantId)) {
return true;
}
return children.some(child => hasDescendant(child.id, possibleDescendantId));
}
function updateGalleryStructure() {
// Create a new array to hold the updated gallery structure
const newGalleries = [];
// Function to recursively process nested lists
function processNestedList(container, parentId = null) {
if (!container || !container.children) {
console.warn('Invalid container in processNestedList', container);
return;
}
const items = container.children;
Array.from(items).forEach(item => {
if (item.tagName !== 'LI') return; // Skip non-list items
const id = parseInt(item.dataset.id);
if (isNaN(id)) {
console.warn('Invalid ID in list item:', item);
return;
}
const gallery = galleries.find(g => g.id === id);
if (gallery) {
// Create a new gallery object without children
const newGallery = { ...gallery };
if (newGallery.children) delete newGallery.children;
// Update parent ID
newGallery.parentId = parentId;
// Add to new galleries array
newGalleries.push(newGallery);
// Process any nested OL with class 'nested-sortable' within this item
const nestedList = item.querySelector('ol.nested-sortable');
if (nestedList) {
processNestedList(nestedList, id);
}
} else {
console.warn(`Gallery with ID ${id} not found in galleries array`);
}
});
}
// Start processing from the root list
const rootList = document.querySelector('#galleryTree > ol.nested-sortable');
if (rootList) {
try {
processNestedList(rootList);
// Verify that we haven't lost any galleries
if (newGalleries.length < galleries.length) {
console.error(`Gallery count mismatch! Original: ${galleries.length}, New: ${newGalleries.length}`);
// Find missing galleries
const originalIds = galleries.map(g => g.id);
const newIds = newGalleries.map(g => g.id);
const missingIds = originalIds.filter(id => !newIds.includes(id));
if (missingIds.length > 0) {
console.error('Missing gallery IDs:', missingIds);
// Add missing galleries to the new structure
missingIds.forEach(id => {
const missingGallery = galleries.find(g => g.id === id);
if (missingGallery) {
// Add it as a top-level item
const newGallery = { ...missingGallery, parentId: null };
if (newGallery.children) delete newGallery.children;
newGalleries.push(newGallery);
console.log(`Rescued gallery: ${newGallery.title} (ID: ${newGallery.id})`);
}
});
}
}
// Update the galleries array with the new structure
galleries = newGalleries;
// Save the updated structure
saveGalleries();
} catch (error) {
console.error('Error in updateGalleryStructure:', error);
throw error; // Re-throw to trigger the backup restore
}
} else {
console.warn('Root list not found');
throw new Error('Root list not found'); // Throw error to trigger backup restore
}
}
function toggleSubmenu(id, event) {
// Ensure the event doesn't interfere with drag operations
event.stopPropagation();
const listItem = document.querySelector(`li[data-id="${id}"]`);
if (listItem) {
// Toggle the expanded class
listItem.classList.toggle('expanded');
// Find the submenu toggle icon and rotate it
const toggleIcon = listItem.querySelector('.toggle-icon');
if (toggleIcon) {
toggleIcon.classList.toggle('rotated');
}
}
}
/**
* Improved slugify function with robust character handling
* @param {string} text - The text to convert to a slug
* @returns {string} A URL-friendly slug
*/
function slugify(text) {
// Guard against null or empty input
if (!text || text.trim() === '') {
return 'page-' + Date.now();
}
// First convert to lowercase
let slug = text.toLowerCase();
// Replace spaces with hyphens
slug = slug.split(' ').join('-');
// Filter out unwanted characters (keep only a-z, 0-9, and hyphens)
let filtered = '';
for (let i = 0; i < slug.length; i++) {
const char = slug.charAt(i);
if ((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char === '-') {
filtered += char;
}
}
slug = filtered;
/*
// Clean up multiple hyphens
while (slug.indexOf('--') !== -1) {
slug = slug.replace('--', '-');
}
*/
// Remove leading and trailing hyphens
if (slug.startsWith('-')) {
slug = slug.substring(1);
}
if (slug.endsWith('-')) {
slug = slug.substring(0, slug.length - 1);
}
// If slug is empty after processing, use a default
if (!slug || slug === '-') {
return 'page-' + Date.now();
}
return slug;
}
// Update the addPage function to use our new robust slugify function
/**
* Adds a new page to the galleries array
* @returns {number|null} The new page ID or null if error
*/
function addPage() {
try {
// Get the page title
const titleInput = document.getElementById('galleryTitle');
if (!titleInput) {
console.error('Page title input not found');
return;
}
const title = titleInput.value.trim();
// Validate title is not empty
if (!title) {
alert('Please enter a page title');
return;
}
// Get parent ID with error handling
const parentSelect = document.getElementById('galleryParent');
const parentId = parentSelect && parentSelect.value ? parseInt(parentSelect.value) : null;
// Generate a unique ID for the page
const id = Date.now();
const pageId = `page_${id}`;
// Calculate slug using our more robust slugify function
const pageSlug = slugify(title);
// Create the page object with explicit properties
const page = {
id: id,
title: title,
isPage: true,
isIntegrated: true,
isSubmenu: false,
pageId: pageId,
visible: true,
parentId: parentId,
siteId: siteId,
// Set slug and URL explicitly
slug: pageSlug,
url: '/' + pageSlug
};
console.log(`Creating new page with ID: ${id}, title: "${title}"`);
// Add to galleries array
galleries.push(page);
// CRITICAL: Ensure window.galleries is synchronized
if (typeof window.galleries === 'undefined') {
window.galleries = galleries;
} else {
// Make sure the page is in window.galleries too
const existingIndex = window.galleries.findIndex(g => g.id === id);
if (existingIndex >= 0) {
window.galleries[existingIndex] = page;
} else {
window.galleries.push(page);
}
}
// Initialize empty page elements array in PageManager
if (window.PageManager) {
window.PageManager.elements[pageId] = [];
}
// Update UI first
renderGalleries();
clearAddForm();
toggleAddForm();
// Set as active page
console.log(`Setting new page "${page.title}" as active`);
activeGalleryId = id;
window.activeGalleryId = id;
// Update active states in the menu
updateActiveStates();
// Save to server
saveGalleries().then(() => {
console.log(`Page "${page.title}" saved successfully`);
// Double check that our gallery is still in the array
const pageExists = galleries.some(g => g.id === id);
console.log(`Page exists in galleries array: ${pageExists}`);
if (pageExists) {
// Load the new page after a slight delay to ensure DOM is updated
setTimeout(() => {
console.log(`Loading new page "${page.title}" with ID: ${id}`);
loadPage(id);
}, 50);
} else {
console.error(`Page with ID ${id} not found in galleries array after save`);
}
}).catch(error => {
alert('Error saving page. Please try again.');
});
return id;
} catch (error) {
console.error('Error adding page:', error);
alert('Error adding page. Please try again.');
return null;
}
}
/**
* Ensures a slug is unique among existing galleries
* @param {string} slug - The base slug to check
* @param {Array} existingGalleries - Array of existing gallery configs
* @returns {string} A unique slug
*/
function ensureUniqueSlug(slug, existingGalleries) {
// Guard against invalid slugs
if (!slug || slug === '-') {
slug = 'page-' + Date.now();
}
let finalSlug = slug;
let counter = 1;
// Check if slug already exists
while (existingGalleries.some(g => g.slug === finalSlug)) {
finalSlug = `${slug}-${counter}`;
counter++;
}
return finalSlug;
}
/**
* Adds a new gallery based on the selected type
* @returns {number|null} The new gallery ID or null if error
*/
function addGallery() {
try {
// Get the gallery title
const titleInput = document.getElementById('galleryTitle');
if (!titleInput) {
console.error('Gallery title input not found');
return;
}
const title = titleInput.value.trim();
// Get the selected gallery type
const galleryTypeRadio = document.querySelector('input[name="galleryType"]:checked');
if (!galleryTypeRadio) {
console.error('No gallery type selected');
alert('Please select a gallery type');
return;
}
const galleryType = galleryTypeRadio.value;
console.log("Selected gallery type:", galleryType);
// If this is a page, use the addPage function
if (galleryType === 'page') {
console.log("Creating page instead of gallery");
return addPage();
}
// For spacer type, we don't require a title
if (galleryType !== 'spacer' && !title) {
alert('Please enter a gallery title');
return;
}
// Continue with gallery creation logic
// Generate unique ID
const id = Date.now();
// Get parent gallery if selected
const parentSelect = document.getElementById('galleryParent');
const parentId = parentSelect.value ? parseInt(parentSelect.value) : null;
// Get URL if this is an external gallery
const galleryUrl = document.getElementById('galleryUrl');
const url = galleryTypeRadio.value === 'external' && galleryUrl
? galleryUrl.value.trim()
: "";
// Set basic properties
const gallery = {
id: id,
title: title || "Spacer", // Use "Spacer" as internal title if no title provided
parentId: parentId,
visible: true
};
// Set type-specific properties
if (galleryTypeRadio.value === 'spacer') {
// Spacer properties
gallery.isSpacer = true;
gallery.title = title || "Spacer"; // For internal reference
} else if (galleryTypeRadio.value === 'integrated') {
// Generate pageId for integrated galleries
gallery.isIntegrated = true;
gallery.pageId = generatePageId();
// Generate and ensure unique slug
const baseSlug = slugify(title);
gallery.slug = ensureUniqueSlug(baseSlug, galleries);
gallery.url = "/" + gallery.slug;
// Get the siteId from multiple possible sources
const siteId = window.Parameters?.siteId ||
window.siteId ||
document.querySelector('meta[name="hydra-site-id"]')?.getAttribute('content') ||
'';
// Add galleryOptions with GUID=siteId as the manualCollectionName
gallery.galleryOptions = {
manualCollectionName: `GUID=${siteId}`,
isIntegratedGallery: true,
pageId: gallery.pageId, // Store pageId for reference
siteId: siteId
};
} else if (galleryTypeRadio.value === 'folder') {
gallery.isFolder = true;
gallery.isCollapsed = true; // Make folders closed by default
} else if (galleryTypeRadio.value === 'external') {
// External URL
gallery.url = url;
gallery.isExternal = true; // Add this flag to identify external URLs
}
console.log(`Creating new gallery with ID: ${id}, title: "${title}"`);
// Add to galleries array
galleries.push(gallery);
// CRITICAL: Ensure window.galleries is synchronized
if (typeof window.galleries === 'undefined') {
window.galleries = galleries;
} else {
// Make sure the gallery is in window.galleries too
const existingIndex = window.galleries.findIndex(g => g.id === id);
if (existingIndex >= 0) {
window.galleries[existingIndex] = gallery;
} else {
window.galleries.push(gallery);
}
}
// Update UI first
renderGalleries();
clearAddForm();
toggleAddForm();
// For non-folder types, activate and load the new gallery immediately
if (!gallery.isFolder && !gallery.isSubmenu && !gallery.isSpacer) {
console.log(`Setting new gallery "${gallery.title}" as active`);
// Set as active gallery
activeGalleryId = id;
window.activeGalleryId = id;
// Update active states in the menu
updateActiveStates();
}
// Save galleries after UI update
saveGalleries().then(() => {
console.log(`Gallery "${gallery.title}" saved successfully`);
// Double check that our gallery is still in the array
const galleryExists = galleries.some(g => g.id === id);
console.log(`Gallery exists in galleries array: ${galleryExists}`);
// For non-folder types, load the new gallery content after a slight delay
if (!gallery.isFolder && !gallery.isSubmenu && !gallery.isSpacer && gallery.visible !== false) {
setTimeout(() => {
console.log(`Loading new gallery "${gallery.title}" with ID: ${id}`);
if (gallery.isIntegrated) {
loadGallery(id);
} else if (gallery.isExternal && gallery.url) {
// For external URLs, set the iframe src
const frame = document.getElementById('galleryFrame');
if (frame) frame.src = gallery.url;
}
}, 50);
}
}).catch(error => {
console.error(`Error saving gallery "${gallery.title}":`, error);
alert('Error saving gallery. Please try again.');
});
// Return the new gallery ID
return id;
} catch (error) {
console.error('Error adding gallery:', error);
alert('Error adding gallery. Please try again.');
}
}
function debugDOMState(message) {
console.log(`[DEBUG] ${message}`);
const galleryContainer = document.querySelector('.gallery-container');
const iframe = document.getElementById('galleryFrame');
const pageContainers = document.querySelectorAll('.page-container');
console.log(`- Gallery container: ${galleryContainer ? 'found' : 'not found'}`);
console.log(`- iframe: ${iframe ? 'found' : 'not found'}, display: ${iframe?.style.display}`);
console.log(`- Page containers: ${pageContainers.length} found`);
pageContainers.forEach((container, index) => {
console.log(` - Container ${index}: display: ${container.style.display}, visibility: ${container.style.visibility}`);
});
}
/**
* Global helper function to check if we're in edit mode
* This centralizes the logic for detecting edit mode across all components
*/
window.isInEditMode = function() {
// Method 1: Check global isEditing variable
if (typeof window.isEditing === 'boolean') {
return window.isEditing;
}
// Method 2: Check sidebar editing class
const sidebar = document.querySelector('.sidebar');
if (sidebar && sidebar.classList.contains('editing')) {
return true;
}
// Method 3: Check Parameters.isInEditor
if (window.Parameters && typeof window.Parameters.isInEditor === 'boolean') {
return window.Parameters.isInEditor;
}
// Method 4: Check if any page containers have editing class
const editingContainers = document.querySelectorAll('.page-container.editing');
if (editingContainers && editingContainers.length > 0) {
return true;
}
// Default to false if no indicators found
return false;
};
/**
* This function synchronizes edit mode state across all components
*/
window.updateGlobalEditState = function(isEditing) {
console.log('Updating global edit state:', isEditing);
// Update global variable
window.isEditing = isEditing;
// Update Parameters object if it exists
if (window.Parameters) {
window.Parameters.isInEditor = isEditing;
}
// Update sidebar class
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
if (isEditing) {
sidebar.classList.add('editing');
} else {
sidebar.classList.remove('editing');
}
}
// Update page containers
const pageContainers = document.querySelectorAll('.page-container');
pageContainers.forEach(container => {
if (isEditing) {
container.classList.add('editing');
} else {
container.classList.remove('editing');
}
});
// Notify all components via event
document.dispatchEvent(new CustomEvent('edit-mode-changed', {
detail: { editing: isEditing }
}));
};
/**
* loadPage function that safely handles undefined galleries
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
function loadPage(id, event){
if (event) {
event.preventDefault();
event.stopPropagation();
const now = Date.now();
const lastCallTime = window._lastPageLoadTime || 0;
window._lastPageLoadTime = now;
if (now - lastCallTime < 100) {
console.log('Ignoring duplicate loadPage call');
return;
}
}
console.log('loadPage: Loading page with gallery ID:', id);
stopAutoAdvanceTimer();
let galleriesData = window.galleries || galleries;
const gallery = findGalleryById(galleriesData, id);
if (!gallery) {
console.warn('No gallery found with ID:', id, 'for page load.');
return;
}
if (typeof window.removeGalleryScriptsWithPause === 'function') {
window.removeGalleryScriptsWithPause();
}
window.activeGalleryId = id;
activeGalleryId = id;
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '';
}
if (!gallery.pageId) gallery.pageId = `page_${id}`;
if (!gallery.isPage) gallery.isPage = true;
if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {};
window._pageIdToGalleryId[gallery.pageId] = id;
if (gallery.pageElements && Array.isArray(gallery.pageElements)) {
if (window.PageManager && window.PageManager.elements) {
window.PageManager.elements[gallery.pageId] = [...gallery.pageElements];
}
} else if (window.PageManager && window.PageManager.elements &&
window.PageManager.elements[gallery.pageId] &&
window.PageManager.elements[gallery.pageId].length > 0) {
gallery.pageElements = [...window.PageManager.elements[gallery.pageId]];
}
if (window.PageManager && typeof window.PageManager.loadPage === 'function') {
try {
window.PageManager.loadPage(gallery.pageId);
const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false;
if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') {
window.PageManager.setEditMode(isCurrentlyEditing);
}
} catch (error) {
console.error('Error loading page with PageManager:', error);
}
} else {
console.error('PageManager not found or loadPage method not available');
}
// Apply menu visibility
const bodyEl = document.body;
if (gallery.hideMenuOnPage && !isInEditMode()) { // Check if not in edit mode
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
// Close the gallery options panel when navigating to a different page
if (typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery);
}
function generatePageId() {
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const charactersLength = characters.length;
for (let i = 0; i < 8; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
function normalizeNameForComparison(name) {
if (!name) {
console.log("Empty name passed to normalization");
return '';
}
console.log(`Normalizing name: "${name}"`);
// Step 1: Convert to lowercase
let normalized = name.toLowerCase();
console.log(`After lowercase: "${normalized}"`);
// Step 2: Replace spaces with hyphens (without regex)
normalized = normalized.split(' ').join('-');
console.log(`After space replacement: "${normalized}"`);
// Step 3: Filter out unwanted characters (without regex)
let filtered = '';
for (let i = 0; i < normalized.length; i++) {
const char = normalized.charAt(i);
// Keep only a-z, 0-9, and hyphens
if ((char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
char === '-') {
filtered += char;
}
}
normalized = filtered;
console.log(`After character filtering: "${normalized}"`);
// Step 4: Clean up multiple hyphens (without regex)
while (normalized.indexOf('--') !== -1) {
normalized = normalized.split('--').join('-');
}
// Step 5: Remove leading and trailing hyphens (without regex)
if (normalized.charAt(0) === '-') {
normalized = normalized.substring(1);
}
if (normalized.charAt(normalized.length - 1) === '-') {
normalized = normalized.substring(0, normalized.length - 1);
}
// Check if the result is valid
if (!normalized || normalized === '-') {
console.log(`WARNING: Normalization produced invalid result: "${normalized}"`);
} else {
console.log(`Final normalized slug: "${normalized}"`);
}
return normalized;
}
function ensureUniqueSlug(slug, existingGalleries) {
if (!slug) return 'gallery';
let finalSlug = slug;
let counter = 1;
// Check if slug exists in any gallery
const slugExists = function(s) {
for (let i = 0; i < existingGalleries.length; i++) {
if (existingGalleries[i].slug === s) {
return true;
}
}
return false;
};
// Keep incrementing counter until unique
while (slugExists(finalSlug)) {
finalSlug = slug + '-' + counter;
counter++;
}
return finalSlug;
}
/**
* Gathers the current complete site configuration from the client-side state
* and triggers a download of the data as a single JSON file.
* This provides a simple way to back up or export the current site structure.
*/
function downloadCurrentSiteJSON() {
try {
console.log('Gathering current site data for download...');
// Get the current hostname to use in the filename.
const hostname = window.location.hostname.replace('www.', '');
// --- FIX: Ensure we get the most up-to-date menu styles ---
// The MenuStyleCustomizer.settings object is the most reliable source
// for the current styles being used on the client.
const currentMenuStyles = window.MenuStyleCustomizer?.settings || {};
// Get current galleries
const currentGalleries = window.galleries || galleries || [];
// --- STREAMLINED: Process galleries without including image data ---
const enhancedGalleries = currentGalleries.map(gallery => {
// If this is a Classic collection gallery, add note about manual retrieval
if (gallery.galleryOptions?.manualCollectionName?.startsWith('GUID=')) {
const classicGuid = gallery.galleryOptions.manualCollectionName.substring(5);
console.log(`Processing Classic collection gallery: ${gallery.title} (GUID: ${classicGuid})`);
return {
...gallery,
_note: `This gallery references Classic collection GUID=${classicGuid}. To get the full image data with captions, you may need to manually fetch the Classic collection JSON from: https://storage.neonsky.app/sites:site_${classicGuid}.json`,
_classicGuid: classicGuid,
_requiresClassicData: true
};
}
return gallery;
});
// Assemble the complete site configuration object from global state variables.
// This structure mirrors what the `save-config` API endpoint expects, ensuring
// the exported file is a complete and re-importable replica.
const siteData = {
// The core menu/page structure with enhanced Classic collection data.
galleries: enhancedGalleries,
// Admin emails are managed server-side for security. We export the list if it's
// available on the client from a previous check, but the server remains the
// source of truth. A re-import will not overwrite the admin list.
adminEmails: window.siteConfig?.adminEmails || [],
// The unique ID for the site.
siteId: window.siteId || window.Parameters?.siteId || '',
// Custom styling rules for the menu.
menuStyles: currentMenuStyles,
// All elements configured to appear in the sidebar.
sidebarElements: window.SidebarManager?.elements || [],
// Global site metadata for SEO and analytics.
siteMetadata: window.siteMetadata || {},
// Add metadata about Classic collections that need manual data retrieval
_classicCollections: enhancedGalleries
.filter(g => g._requiresClassicData)
.map(g => ({
title: g.title,
guid: g._classicGuid,
note: g._note
}))
};
console.log('Enhanced site data collected for export:', siteData);
// Convert the JavaScript object to a formatted JSON string.
const jsonString = JSON.stringify(siteData, null, 2);
// Create a Blob, which is a file-like object of immutable, raw data.
const blob = new Blob([jsonString], { type: 'application/json' });
// Create a temporary link element to trigger the browser's download functionality.
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${hostname}-config.json`; // e.g., 'yoursite.com-config.json'
document.body.appendChild(a);
a.click();
// Clean up by removing the temporary link and revoking the object URL to free up memory.
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('Enhanced JSON download initiated successfully.');
// Show a helpful message about Classic collections
const classicCount = siteData._classicCollections?.length || 0;
if (classicCount > 0) {
console.log(`Export Summary: ${classicCount} Classic collection(s) detected. The exported JSON includes gallery structure and settings, but for full image data with captions, you may need to manually fetch the Classic collection JSON files.`);
}
} catch (error) {
console.error('Error creating JSON download:', error);
alert('An error occurred while preparing the download. Please check the console.');
}
}
// Build Hydra Classic-style NDJSON (first line: metadata; second line: imageMetadata array)
function buildHydraClassicNdjson(classicGuid, images) {
const header = {
title: 'Untitled',
description: '',
version: '1',
isClassic: true,
isClassicCollection: true,
classicGuid: 'GUID=' + String(classicGuid || '')
};
const imageArray = Array.isArray(images) ? images.map(function(img, idx) {
var path = '';
if (img) {
path = img.image || img.link || img.originalUrl || (img.urls && img.urls.original) || '';
}
// Derive filename base from any provided path/filename and normalize extension to .jpg
var fnameBase = '';
if (typeof path === 'string' && path.length > 0) {
var slash = path.lastIndexOf('/');
var raw = slash >= 0 ? path.substring(slash + 1) : path;
// remove query/hash
var q = raw.indexOf('?'); if (q >= 0) raw = raw.substring(0, q);
var h = raw.indexOf('#'); if (h >= 0) raw = raw.substring(0, h);
// strip extension if present
var dot = raw.lastIndexOf('.');
fnameBase = dot > 0 ? raw.substring(0, dot) : raw;
} else if (img && typeof img.filename === 'string') {
var rf = img.filename;
var q2 = rf.indexOf('?'); if (q2 >= 0) rf = rf.substring(0, q2);
var h2 = rf.indexOf('#'); if (h2 >= 0) rf = rf.substring(0, h2);
var d2 = rf.lastIndexOf('.');
fnameBase = d2 > 0 ? rf.substring(0, d2) : rf;
}
var filename = fnameBase ? (fnameBase + '.jpg') : '';
// Build CDN image URL using site GUID: https://cdn.neonsky.app/{siteGuid}/images/{filename}
// Note: classicGuid is a site GUID, not an image GUID - it identifies the site, and images are stored under that site's path
var guidRaw = String(classicGuid || '').replace(/^GUID=/, '');
var cdnImageUrl = filename && guidRaw ? ('https://cdn.neonsky.app/' + guidRaw + '/images/' + filename) : path;
var rec = {
caption: (img && (img.caption || img.description)) || '',
description: (img && (img.description || img.caption)) || '',
isPreview: idx === 1,
title: (img && img.title) || '',
'alt-text': (img && (img['alt-text'] || img.altText || '')) || '',
image: cdnImageUrl || '',
link: '',
'link-text': '',
filename: filename,
isClassic: true,
isClassicCollection: true,
classicGuid: 'GUID=' + String(classicGuid || '')
};
return rec;
}) : [];
// Build NDJSON as: header line, imageMetadata array line, then one line per full image record
var body = { imageMetadata: imageArray };
var out = [JSON.stringify(header), JSON.stringify(body)];
for (var k = 0; k < imageArray.length; k++) {
out.push(JSON.stringify(imageArray[k]));
}
return out.join('\n') + '\n';
}
async function uploadNdjsonToTigris(siteId, pageId, ndjsonContent) {
// Dual upload strategy: Fly.io (uncompressed) + Irys (compressed if <800KB)
const fileName = String(siteId || '') + '_' + String(pageId || '') + '.ndjson';
const flyUrl = 'https://hydra-press-v2.fly.dev/publish';
const irysUrl = 'https://hydra-press-v2.fly.dev/upload-irys';
console.error('Starting dual NDJSON upload for:', fileName);
console.error('Content length:', ndjsonContent.length, 'characters');
// COMPRESSION: Compress for Irys only (not Tigris)
console.error('🔄 Compressing NDJSON with gzip for Irys...');
const encoder = new TextEncoder();
const ndjsonBytes = encoder.encode(ndjsonContent);
const compressedStream = new Response(ndjsonBytes).body.pipeThrough(new CompressionStream('gzip'));
const compressedBlob = await new Response(compressedStream).blob();
const compressedBytes = new Uint8Array(await compressedBlob.arrayBuffer());
const originalSize = ndjsonBytes.length;
const compressedSize = compressedBytes.length;
const compressionRatio = ((1 - compressedSize / originalSize) * 100).toFixed(2);
console.error('📊 Compression results:', {
original: originalSize + ' bytes (' + (originalSize / 1024).toFixed(2) + ' KB)',
compressed: compressedSize + ' bytes (' + (compressedSize / 1024).toFixed(2) + ' KB)',
ratio: compressionRatio + '%'
});
const IRYS_SIZE_LIMIT = 800 * 1024; // 800KB limit for compressed Irys upload
const shouldUploadToIrys = compressedSize < IRYS_SIZE_LIMIT;
console.error('Irys upload eligible: ' + shouldUploadToIrys + ' (compressed: ' + (compressedSize / 1024).toFixed(2) + ' KB)');
// STEP 1: Upload to Irys (non-blocking, compressed if <800KB)
let irysResult = null;
if (shouldUploadToIrys) {
try {
console.error('🔄 Starting Irys upload (compressed, non-blocking)...');
// Convert compressed bytes to base64
const compressedBase64 = btoa(String.fromCharCode(...compressedBytes));
const irysResp = await fetch(irysUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
compressedData: compressedBase64,
originalSize: originalSize,
compressedSize: compressedSize,
siteId: siteId,
pageId: pageId
})
});
if (irysResp.ok) {
irysResult = await irysResp.json();
console.error('✅ Irys upload successful:', irysResult.txId);
console.error(' Irys Gateway:', irysResult.urls?.irys);
console.error(' ar.io Gateway:', irysResult.urls?.arweave);
// Log wallet balance and cost
if (irysResult.walletBalance !== undefined) {
console.error('💰 Wallet Balance:', irysResult.walletBalance.toFixed(6), 'AR');
}
if (irysResult.uploadCost !== undefined) {
const costUSD = (irysResult.uploadCost * 20).toFixed(4);
if (irysResult.uploadCost === 0) {
console.error('💸 Upload Cost: FREE (0 AR)');
} else {
console.error('💸 Upload Cost:', irysResult.uploadCost.toFixed(6), 'AR (~$' + costUSD + ' USD at $20/AR)');
}
}
} else {
const errorText = await irysResp.text().catch(() => 'Unknown error');
console.warn('⚠️ Irys upload failed (non-critical):', irysResp.status, errorText);
}
} catch (irysError) {
console.warn('⚠️ Irys upload error (non-critical):', irysError.message);
// Continue - Irys failure doesn't block Fly.io upload
}
} else {
console.error('⏭️ Skipping Irys upload (compressed size exceeds 800KB limit)');
}
// STEP 2: Upload to Fly.io (CRITICAL - must succeed, uncompressed)
try {
console.error('🔄 Starting Fly.io upload (uncompressed)...');
const flyResp = await fetch(flyUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ndjsonContent, dataFileName: fileName })
});
console.log('Server response status:', flyResp.status);
if (!flyResp.ok) {
const errorText = await flyResp.text().catch(() => 'Unknown error');
console.error('❌ Fly.io upload failed', flyResp.status, 'for', fileName + ':', errorText);
return { success: false, irysResult: null };
}
const flyData = await flyResp.json().catch(() => null);
if (flyData && flyData.urls) {
console.log('✅ Fly.io upload successful:', flyData.urls);
console.log(' Tigris URL:', flyData.urls.urlTigris);
// Return comprehensive result
const result = {
success: true,
flyUrls: flyData.urls,
irysResult: irysResult,
uploadedTo: irysResult ? ['fly', 'irys'] : ['fly'],
fileSize: compressedSize, // Irys compressed size
originalFileSize: originalSize // Original uncompressed size
};
console.log('📦 Upload complete:', {
destinations: result.uploadedTo.join(' + '),
txId: irysResult?.txId || 'N/A',
flyUrl: flyData.urls.urlTigris
});
return result;
} else {
console.warn('Fly.io upload response missing URLs:', flyData);
return { success: false, irysResult: null };
}
} catch (flyError) {
console.error('❌ Fly.io upload error for', fileName + ':', flyError);
return { success: false, irysResult: null };
}
}
async function importClassicGalleries() {
// Browser-scoped helper: mirrors server-side behavior with escaped patterns for template-safe JS
function cleanDescriptionLeadingLines(text) {
if (!text || typeof text !== 'string') return text;
let result = text;
// Remove leading empty TEXTFORMAT blocks:
result = result.replace(
new RegExp(
'^(\s*\]*\>\s*\
]*\>\s*\]*\>\s*\<\/FONT\>\s*\<\/P\>\s*\<\/TEXTFORMAT\>\s*)+',
'i'
),
''
);
// Remove leading empty P tags
result = result.replace(
new RegExp('^(\s*\
]*\>\s*\<\/P\>\s*)+', 'i'),
''
);
// Remove leading empty FONT tags
result = result.replace(
new RegExp('^(\s*\]*\>\s*\<\/FONT\>\s*)+', 'i'),
''
);
// Remove leading BR tags
result = result.replace(
new RegExp('^(\s*\ \s*)+', 'i'),
''
);
// Remove any remaining leading whitespace
return result.replace(new RegExp('^\s+', 'i'), '').trim();
}
// Browser-scoped helper: fix multi-line links (template-safe escaped regex)
function fixMultilineLinks(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(
new RegExp(']*>.*?Click.*?<\/a>]*>.*?HERE.*?<\/a>]*>.*?to Add to Cart.*?<\/a>', 'gis'),
function (match) {
const first = match.match(new RegExp('Click HERE to Add to Cart';
}
return match;
}
);
}
// Browser-scoped helper: convert plain text link phrase to HTML link
function convertPlainTextLinks(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(
new RegExp('Click HERE to Add to Cart', 'g'),
'Click HERE to Add to Cart'
);
}
// Get input elements, including the new checkbox
const guidInputEl = document.getElementById('classicGuid');
const pastedJsonEl = document.getElementById('pastedJson');
const parentIdInputEl = document.getElementById('importParent');
const createSubmenuCheckboxEl = document.getElementById('createSubmenu');
const submenuTitleInputEl = document.getElementById('submenuTitle');
const replaceMenuCheckboxEl = document.getElementById('replaceMenuCheckbox');
// Get values from inputs
const guidInput = guidInputEl instanceof HTMLInputElement ? guidInputEl.value.trim() : '';
const pastedJson = pastedJsonEl instanceof HTMLTextAreaElement ? pastedJsonEl.value.trim() : '';
const replaceMenu = replaceMenuCheckboxEl instanceof HTMLInputElement ? replaceMenuCheckboxEl.checked : false;
let parentId = parentIdInputEl instanceof HTMLSelectElement && parentIdInputEl.value ? parseInt(parentIdInputEl.value) : null;
let createSubmenu = createSubmenuCheckboxEl instanceof HTMLInputElement ? createSubmenuCheckboxEl.checked : false;
let submenuTitle = submenuTitleInputEl instanceof HTMLInputElement ? submenuTitleInputEl.value.trim() : '';
// If replacing the menu, ignore parent/submenu settings
if (replaceMenu) {
parentId = null;
createSubmenu = false;
}
// Validate submenu title if checkbox is checked
if (createSubmenu && !submenuTitle) {
alert('Please provide a title for the submenu');
return;
}
// Show status display
const statusEl = document.getElementById('importStatus');
const progressBarEl = statusEl ? statusEl.querySelector('.progress-bar') : null;
const statusMessageEl = statusEl ? statusEl.querySelector('.status-message') : null;
const progressBar = progressBarEl instanceof HTMLElement ? progressBarEl : null;
const statusMessage = statusMessageEl instanceof HTMLElement ? statusMessageEl : null;
if (statusEl && progressBar && statusMessage) {
statusEl.style.display = 'block';
statusEl.classList.add('importing');
progressBar.style.width = '10%';
statusMessage.textContent = 'Preparing to import...';
}
try {
let classicData;
let classicGuid; // Will hold the primary GUID for image path lookups
// --- LOGIC TO GET DATA: Prioritize Pasted JSON ---
if (pastedJson) {
if (statusMessage) statusMessage.textContent = 'Parsing pasted JSON...';
classicData = JSON.parse(pastedJson);
if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
throw new Error('Pasted JSON is invalid: "galleries" array not found.');
}
// Use the first available GUID from the pasted data as the default for images
classicGuid = classicData.galleries[0]?.classicGuid || classicData.siteId;
} else if (guidInput) {
// Check if this is the new import_ format
if (guidInput.startsWith('import_')) {
if (statusMessage) statusMessage.textContent = 'Fetching data via import_ format...';
const importGuid = guidInput.substring(7); // Remove 'import_' prefix
const jsonUrl = `https://hydra.neonsky.app/json-imports/${guidInput}.json`;
const response = await fetch(jsonUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${jsonUrl}`);
classicData = await response.json();
if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
throw new Error('Fetched import JSON is invalid: "galleries" array not found.');
}
// HYBRID APPROACH: Also fetch the original GUID JSON for image metadata
if (statusMessage) statusMessage.textContent = 'Fetching image metadata from original JSON...';
const originalJsonUrl = `https://cdn.neonsky.app/sites:site_${importGuid}.json`;
try {
const originalResponse = await fetch(originalJsonUrl);
if (originalResponse.ok) {
const originalData = await originalResponse.json();
// Get the default language from site configuration
let defaultLanguageID = 'en'; // fallback
if (originalData.languages && Array.isArray(originalData.languages)) {
const defaultLang = originalData.languages.find(lang => lang.isDefault === true);
if (defaultLang) {
defaultLanguageID = defaultLang.id;
}
}
// Merge image metadata from original JSON into import JSON galleries
if (originalData.galleries && Array.isArray(originalData.galleries)) {
classicData.galleries = classicData.galleries.map(importGallery => {
// Find matching gallery in original data by name or categoryID
const originalGallery = originalData.galleries.find(orig =>
orig.title === importGallery.title || // Primary match by title
orig.name === importGallery.title || // Fallback: orig.name matches import.title
orig.categoryID === importGallery.categoryID // Fallback: categoryID match
);
if (originalGallery && originalGallery.images && Array.isArray(originalGallery.images)) {
// Process all images from original gallery with metadata extraction
const processedImages = originalGallery.images.map(originalImage => {
// Apply EXACT same metadata processing logic as Edition Publisher
let title = originalImage.title || '';
let dateline = originalImage.dateline || '';
let caption = originalImage.caption || '';
// Check languageVersions array for metadata (this is where most data is stored)
if (originalImage.languageVersions && originalImage.languageVersions.length > 0) {
// Look for site's default language first, then fall back to first available
let langData = originalImage.languageVersions.find(lang => lang.languageID === defaultLanguageID);
if (!langData) {
langData = originalImage.languageVersions[0];
}
// For language-enabled JSON files, prioritize languageVersions data
// Only fall back to top-level if languageVersions data is empty
if (!title || title.trim() === '') title = langData.title || '';
if (!dateline || dateline.trim() === '') dateline = langData.dateline || '';
if (!caption || caption.trim() === '') caption = langData.caption || '';
}
// Convert Flash TEXTFORMAT to HTML (properly escaped for template literals)
let description = caption;
if (caption && caption.includes('TEXTFORMAT')) {
try {
// Use properly escaped HTML characters for template literal context
description = caption
.replace(/\]*\>/g, '')
.replace(/\<\/TEXTFORMAT\>/g, '')
.replace(/\
');
processedText = processedText.replace(new RegExp('', 'gi'), '').replace(new RegExp('', 'gi'), '');
processedText = processedText.replace(new RegExp('', 'gi'), '').replace(new RegExp('', 'gi'), '');
processedText = processedText.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
// Apply description fixes to processed text
processedText = cleanDescriptionLeadingLines(processedText);
processedText = fixMultilineLinks(processedText);
processedText = convertPlainTextLinks(processedText);
rightColumnElements.push({
id: newItemId + 3200, type: "text", title: "Text Block Content", visible: true, position: 0,
textContent: processedText, textWidth: 70
});
}
columns.push({ id: newItemId + 3300, hAlign: "right", vAlign: "middle", elements: leftColumnElements });
columns.push({ id: newItemId + 3400, hAlign: "left", vAlign: "middle", elements: rightColumnElements });
pageElements.push({
id: newItemId + 3000, type: "column-container", title: "Imported Columns", visible: true, position: 2,
columns, backgroundSlideshow: { enabled: false, slides: [], slideDuration: 5000, transitionDuration: 500, slideshowHeight: 50, showFullImages: false }
});
}
newItem = {
id: newItemId, title, isPage: true, isIntegrated: true, isSubmenu: false, pageId: pageUniqueId, visible: classicItem.visible !== false, parentId: null,
siteId: currentSiteId, slug: finalSlug, url: `/${finalSlug}`, pageElements,
classicCategoryId: classicItem.categoryID, classicGuid: itemGuid, importSource: 'classic-page'
};
} else if (classicItem.isFolder === true) {
// --- Handle folders from import_guid.json format ---
console.log(`Creating folder: "${title}"`);
newItem = {
id: newItemId,
title,
isFolder: true,
isCollapsed: classicItem.isCollapsed !== false,
visible: classicItem.visible !== false,
parentId: null,
siteId: currentSiteId,
slug: finalSlug,
importSource: 'folder'
};
} else {
// --- This is a Gallery ---
const galleryPageId = generatePageId();
// --- ENHANCED: Try to fetch and include actual image data with captions ---
let enhancedGalleryOptions = {
manualCollectionName: `GUID=${itemGuid}`,
categoryId: classicItem.categoryID,
importedFromClassic: true,
siteId: currentSiteId,
pageId: galleryPageId
};
// --- ENHANCED: Preserve gallery display settings from Classic JSON ---
if (classicItem.galleryOptions) {
console.log(`Preserving gallery settings for: ${title}`);
// Preserve layout and display settings
const preservedSettings = [
'startInSingles', 'layoutType', 'columns', 'spacing',
'showDescription', 'showFilename', 'displayAllInfo',
'navigationMode', 'showTextBlock', 'fixedHeroImage',
'zoomInLightbox', 'lightboxOnMobile', 'fadeDuration',
'fadeInDuration', 'descriptionTextColor', 'gridImageOverlayColor',
'gridImageOverlayOpacity', 'lightboxBgColor', 'lightboxBgOpacity',
'lightboxCloseColor', 'lightboxArrowColor', 'useTitles',
'useLinks', 'desktopTitleDisplayMode', 'titleTextAlign',
'showDescriptionInOverlay', 'includeRolloverImageInOverlay',
'filterMenuEnabled', 'filterMenuCollectionNames', 'filterMenuTitles',
'filterMenuStyle', 'rolloverSwap', 'rolloverCollectionNames',
'openMultipleLightboxes', 'batchSize', 'autoplaySingles',
'autoplayDuration', 'autoplayTransition'
];
preservedSettings.forEach(setting => {
if (classicItem.galleryOptions[setting] !== undefined) {
enhancedGalleryOptions[setting] = classicItem.galleryOptions[setting];
}
});
// Preserve text content
if (classicItem.galleryOptions.horizontalScrollerText) {
enhancedGalleryOptions.horizontalScrollerText = classicItem.galleryOptions.horizontalScrollerText;
}
// Preserve JSON collections if present
if (classicItem.galleryOptions.jsonCollections) {
enhancedGalleryOptions.jsonCollections = classicItem.galleryOptions.jsonCollections;
} else {
// Create jsonCollections structure with processed image metadata
const collectionKey = 'GUID=' + itemGuid;
const processedImages = classicItem.images || [];
enhancedGalleryOptions.jsonCollections = {
[collectionKey]: {
isClassicCollection: true,
guid: collectionKey,
metadata: processedImages,
totalItems: processedImages.length
}
};
console.log('Created jsonCollections for ' + title + ' with ' + processedImages.length + ' images');
// Build and try to upload NDJSON for this gallery.
// If upload succeeds, switch to metadata = {} so frontend fetches NDJSON.
// Skip NDJSON generation for galleries with no images to avoid creating empty files
if (processedImages.length > 0) {
try {
console.log('Building and uploading NDJSON for gallery:', title, '(' + processedImages.length + ' images)');
const ndjsonContent = buildHydraClassicNdjson(itemGuid, processedImages);
console.log('Generated NDJSON content length:', ndjsonContent.length, 'characters');
const uploadResult = await uploadNdjsonToTigris(currentSiteId, galleryPageId, ndjsonContent);
if (uploadResult && uploadResult.success) {
try {
enhancedGalleryOptions.jsonCollections[collectionKey].metadata = {};
enhancedGalleryOptions.jsonCollections[collectionKey].totalItems = processedImages.length;
console.log('NDJSON uploaded successfully for "' + title + '"; switched metadata to {}');
// Store dual upload metadata if Irys was successful
if (uploadResult.irysResult && uploadResult.irysResult.txId) {
enhancedGalleryOptions.txId = uploadResult.irysResult.txId;
enhancedGalleryOptions.permaURL = uploadResult.irysResult.txId; // Plain txId for racing
enhancedGalleryOptions.isPerma = true;
enhancedGalleryOptions.uploadedTo = uploadResult.uploadedTo;
enhancedGalleryOptions.fileSize = uploadResult.fileSize;
enhancedGalleryOptions.siteId = currentSiteId;
enhancedGalleryOptions.pageId = galleryPageId;
console.log('✅ Dual upload complete - Irys txId:', uploadResult.irysResult.txId);
console.log(' Uploaded to:', uploadResult.uploadedTo.join(' + '));
} else {
console.log('✅ Single upload (Fly.io only) - No Irys data');
}
} catch (applyErr) {
console.warn('Failed to switch metadata to {} after NDJSON upload:', applyErr);
}
} else {
console.warn('NDJSON upload failed for "' + title + '"; keeping inline metadata array');
}
} catch (e) {
console.warn('NDJSON build/upload skipped for "' + title + '" due to error:', e);
}
} else {
console.log('Skipping NDJSON generation for "' + title + '" - no images to process');
}
}
// Preserve additional metadata
if (classicItem.galleryOptions.siteAlias) {
enhancedGalleryOptions.siteAlias = classicItem.galleryOptions.siteAlias;
}
if (classicItem.galleryOptions.initialPageUuid) {
enhancedGalleryOptions.initialPageUuid = classicItem.galleryOptions.initialPageUuid;
}
if (classicItem.galleryOptions.initialPageAlias) {
enhancedGalleryOptions.initialPageAlias = classicItem.galleryOptions.initialPageAlias;
}
if (classicItem.galleryOptions.timestamp) {
enhancedGalleryOptions.timestamp = classicItem.galleryOptions.timestamp;
}
}
// Note: For import from pasted JSON, we don't include image data to avoid overwriting existing captions
// Image data is only included when importing from Classic GUID directly
console.log(`Gallery ${title} imported with structure and settings preserved (image data not included to avoid overwriting existing captions)`);
newItem = {
id: newItemId, title, url: `/${finalSlug}`, isIntegrated: true, isPage: false, isSubmenu: false, visible: classicItem.visible !== false, parentId: null,
siteId: currentSiteId, pageId: galleryPageId, classicCategoryId: classicItem.categoryID, classicGuid: itemGuid,
importSource: 'classic-gallery', slug: finalSlug, normalizedName: finalSlug,
galleryOptions: enhancedGalleryOptions
};
}
tempNewItems.push(newItem);
}
// --- Pass 2: Link children to parents using the ID map ---
tempNewItems.forEach(newItem => {
const originalId = Object.keys(originalIdToNewIdMap).find(key => originalIdToNewIdMap[key] === newItem.id);
const originalItem = galleriesForImport.find(g => String(g.id) === String(originalId));
if (originalItem && originalItem.parentId) {
const newParentId = originalIdToNewIdMap[originalItem.parentId];
if (newParentId) {
newItem.parentId = newParentId;
const parentItem = tempNewItems.find(p => p.id === newParentId);
if (parentItem) {
parentItem.isSubmenu = true;
parentItem.isCollapsed = true;
}
} else {
newItem.parentId = submenuParentId;
}
} else {
newItem.parentId = submenuParentId;
}
});
if (replaceMenu) {
console.log("Replacing existing menu structure.");
// If replacing, overwrite the entire galleries array.
galleries = tempNewItems;
} else {
console.log("Appending to existing menu structure.");
// If not replacing, push the new items to the existing array.
galleries.push(...tempNewItems);
}
if (progressBar) progressBar.style.width = '90%';
if (statusMessage) statusMessage.textContent = 'Saving changes...';
// Pass import flag to skip localStorage for large import operations
await saveGalleries({ isImport: true });
if (progressBar) progressBar.style.width = '100%';
if (statusMessage) statusMessage.textContent = `Successfully imported ${tempNewItems.length} items!`;
if (statusEl) statusEl.classList.remove('importing');
setTimeout(() => {
renderGalleries();
toggleImportClassicForm();
if (progressBar) progressBar.style.width = '0';
if (statusEl) statusEl.style.display = 'none';
if (confirm('Import complete! Would you like to reload the page to ensure everything is displayed correctly?')) {
window.location.reload();
}
}, 1000);
} catch (error) {
console.error('Error importing classic galleries:', error);
if (statusMessage) statusMessage.textContent = `Error: ${error.message}`;
if (progressBar) {
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#dc3545';
}
if (statusEl) statusEl.classList.remove('importing');
}
}
/**
* Improved toggleImportClassicForm - Closes other forms first
*/
function toggleImportClassicForm() {
// First check if user is logged in and in edit mode
if (!isAuthenticated || !isAdmin || !window.isInEditMode()) {
alert('You must be logged in as an admin and in edit mode to import galleries.');
return;
}
const formId = 'importClassicForm';
const form = document.getElementById(formId);
if (!form) {
console.error('Import form element not found');
return;
}
// If this form is already open, just close everything
if (window.currentOpenForm === formId) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Show the form
form.style.display = 'block';
// Use setTimeout to allow the display change to take effect before adding the class
setTimeout(() => {
form.classList.add('visible');
}, 10);
// Populate parent options
const parentSelect = document.getElementById('importParent');
if (parentSelect) {
parentSelect.innerHTML = '';
// Add all galleries that could be parents (including submenus)
galleries.forEach(gallery => {
// Only include items that are not already marked as imported
if (!gallery.importSource) {
parentSelect.innerHTML += ``;
}
});
}
// Set default values
const submenuTitle = document.getElementById('submenuTitle');
if (submenuTitle && !submenuTitle.value) {
submenuTitle.value = 'Classic Galleries';
}
// Reset any previous import status
const statusEl = document.getElementById('importStatus');
if (statusEl) {
statusEl.style.display = 'none';
const progressBar = statusEl.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = '0';
progressBar.style.backgroundColor = '#4682B4'; // Reset to blue
}
}
// Set this as the current open form
window.currentOpenForm = formId;
}
// Add event listener for the radio buttons to adjust height when page is selected
document.addEventListener('DOMContentLoaded', function() {
const galleryTypeRadios = document.querySelectorAll('input[name="galleryType"]');
galleryTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const addForm = document.getElementById('addForm');
if (addForm && addForm.classList.contains('visible')) {
const isPageSelected = this.value === 'page';
// Give more height for page type
if (isPageSelected) {
addForm.style.maxHeight = '600px';
} else {
// For other types, measure content height
const contentHeight = addForm.scrollHeight + 30;
addForm.style.maxHeight = Math.max(contentHeight, 500) + 'px';
}
}
});
});
});
/**
* Improved toggleStyleEditor - Closes other forms first
*/
function toggleStyleEditor(display = false) {
const formId = 'menuStyleEditor';
// If this form is already open or display is false, just close everything
if (window.currentOpenForm === formId || display === false) {
closeAllForms();
return;
}
// Otherwise close all forms and open this one
closeAllForms();
// Check if style editor already exists
let styleEditor = document.getElementById(formId);
if (!styleEditor) {
// Get the edit controls element (to place the editor after it)
const editControls = document.querySelector('.edit-controls');
if (editControls && window.MenuStyleCustomizer) {
// Create a wrapper for the style editor
styleEditor = document.createElement('div');
styleEditor.id = formId;
styleEditor.style.display = 'none';
// Insert the editor after the edit controls
editControls.parentNode.insertBefore(styleEditor, editControls.nextSibling);
// Create the style editor UI
window.MenuStyleCustomizer.createStyleEditor(styleEditor);
}
}
// Show the editor
if (styleEditor) {
styleEditor.style.display = 'block';
// Set this as the current open form
window.currentOpenForm = formId;
}
}
/**
* This function closes all forms when escape key is pressed
*/
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && window.currentOpenForm) {
closeAllForms();
}
});
/**
* This function closes all forms when clicking outside any form
*/
document.addEventListener('click', function(e) {
// Only process if a form is open
if (!window.currentOpenForm) return;
// Get the current open form element
const currentForm = document.getElementById(window.currentOpenForm);
if (!currentForm) return;
// Check if the click was inside the form or on a form toggle button
const isInsideForm = currentForm.contains(e.target);
const isFormToggleButton = e.target.closest('[onclick*="toggle"]') !== null;
// If clicked outside the form and not on a toggle button, close all forms
if (!isInsideForm && !isFormToggleButton) {
closeAllForms();
}
});
// Override the original functions with our improved versions
window.toggleAddForm = toggleAddForm;
window.toggleStyleEditor = toggleStyleEditor;
window.toggleMetadataEditor = toggleMetadataEditor;
window.toggleSidebarElementForm = toggleSidebarElementForm;
window.toggleImportClassicForm = toggleImportClassicForm;
window.closeAllForms = closeAllForms;
/**
* Opens the edit form for a gallery item.
* (UPDATED to include Meta Title and Meta Description fields, removed Parent Menu)
* @param {number} id - The ID of the gallery item.
* @param {Event} event - The click event.
*/
function editGallery(id, event) {
event.stopPropagation(); // Prevent event bubbling
const gallery = findGalleryById(galleries, id);
const li = document.querySelector(`li[data-id="${id}"]`);
if (gallery && li) {
const showUrlField = gallery.isPage || gallery.isIntegrated;
const showExternalUrlField = gallery.isExternal;
const currentSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : '');
const currentExternalUrl = gallery.url || '';
// Clear existing content and rebuild form to ensure event listeners are fresh if needed
li.innerHTML = ''; // Clear previous form if any
const formDiv = document.createElement('div');
formDiv.className = 'edit-form';
formDiv.innerHTML = `
${showUrlField ? `
Path after domain name (e.g., yoursite.com/my-gallery). Use lowercase letters, numbers, and hyphens.
` : ''}
${showExternalUrlField ? `
Full URL for the external link (e.g., https://example.com/page).
` : ''}
Overrides site title for this item in search results/browser tabs.
Overrides site description for this item in search results.
Allows for a full-screen page or gallery layout. Menu remains visible in edit mode.
Visitors will need to enter a password to access this page.
Password visitors must enter to access this page.
`;
li.appendChild(formDiv); // Append the new form
// Add event listeners programmatically
const saveButton = formDiv.querySelector(`#saveEditBtn-${id}`);
if (saveButton) {
saveButton.addEventListener('click', (e) => saveEdit(id, e));
}
const cancelButton = formDiv.querySelector(`#cancelEditBtn-${id}`);
if (cancelButton) {
cancelButton.addEventListener('click', () => renderGalleries());
}
// Add password protection toggle functionality
const passwordProtectedCheckbox = formDiv.querySelector('.edit-password-protected');
const passwordField = formDiv.querySelector('.password-field');
if (passwordProtectedCheckbox && passwordField) {
passwordProtectedCheckbox.addEventListener('change', function() {
passwordField.style.display = this.checked ? 'block' : 'none';
});
}
li.classList.add('edit-mode');
} else {
console.error("Could not find gallery or list item for ID:", id);
}
}
/**
* Handles click on a folder item to show/hide its contents
* @param {number} id - The gallery ID
* @param {Event} event - The click event
*/
function toggleFolder(id, event) {
// Ensure the event doesn't interfere with drag operations
event.stopPropagation();
console.log('Toggling folder:', id);
const gallery = galleries.find(g => g.id === id);
if (!gallery) return;
// Toggle the collapsed state
gallery.isCollapsed = !gallery.isCollapsed;
// Find the list item in the DOM
const listItem = document.querySelector(`li[data-id="${id}"]`);
if (listItem) {
// Toggle the expanded class
listItem.classList.toggle('expanded', !gallery.isCollapsed);
// Find the submenu toggle icon and rotate it
const toggleIcon = listItem.querySelector('.toggle-icon');
if (toggleIcon) {
toggleIcon.classList.toggle('rotated', !gallery.isCollapsed);
}
const folderSpan = listItem.querySelector('.menu-item[data-folder="true"]');
}
// Only save if in edit mode
if (window.isInEditMode && window.isInEditMode()) {
saveGalleries();
} else {
// For non-edit mode, optionally save folder states to localStorage for persistence
try {
const folderStates = JSON.parse(localStorage.getItem('folder_states') || '{}');
folderStates[id] = !gallery.isCollapsed;
localStorage.setItem('folder_states', JSON.stringify(folderStates));
} catch (e) {
console.error('Error saving folder state to localStorage:', e);
}
}
}
window.toggleFolder = toggleFolder;
/**
* Toggle password protection for a gallery/page
* @param {number} id - The gallery ID
* @param {Event} event - The click event
*/
function toggleGalleryPasswordProtection(id, event) {
event.stopPropagation();
const gallery = findGalleryById(galleries, id);
if (!gallery) {
console.error("Could not find gallery for password protection toggle, ID:", id);
return;
}
if (gallery.passwordProtected) {
// Remove password protection
if (confirm('Remove password protection from this page?')) {
gallery.passwordProtected = false;
delete gallery.password;
saveGalleries();
renderGalleries();
}
} else {
// Add password protection
const password = prompt('Enter a password for this page:');
if (password && password.trim()) {
gallery.passwordProtected = true;
gallery.password = password.trim();
saveGalleries();
renderGalleries();
}
}
}
window.toggleGalleryPasswordProtection = toggleGalleryPasswordProtection;
/**
* Saves the edited gallery item details.
* (UPDATED to handle Meta Title and Meta Description, removed Parent Menu logic)
* @param {number} id - The ID of the gallery item.
* @param {Event} event - The click event.
*/
function saveEdit(id, event) {
event.stopPropagation();
const li = document.querySelector(`li[data-id="${id}"]`);
if (!li) {
console.error("Could not find list item for saveEdit, ID:", id);
return;
}
const titleInput = li.querySelector('.edit-title');
const slugInput = li.querySelector('.edit-slug');
const externalUrlInput = li.querySelector('.edit-external-url');
const metaTitleInput = li.querySelector('.edit-meta-title');
const metaDescriptionInput = li.querySelector('.edit-meta-description');
const hideMenuInput = li.querySelector('.edit-hide-menu');
const passwordProtectedInput = li.querySelector('.edit-password-protected');
const passwordInput = li.querySelector('.edit-password');
const title = titleInput ? titleInput.value.trim() : '';
const newSlugRaw = slugInput ? slugInput.value.trim() : null;
const externalUrl = externalUrlInput ? externalUrlInput.value.trim() : '';
const metaTitle = metaTitleInput ? metaTitleInput.value.trim() : '';
const metaDescription = metaDescriptionInput ? metaDescriptionInput.value.trim() : '';
const hideMenu = hideMenuInput ? hideMenuInput.checked : false;
const passwordProtected = passwordProtectedInput ? passwordProtectedInput.checked : false;
const password = passwordInput ? passwordInput.value.trim() : '';
const gallery = findGalleryById(galleries, id);
if (!gallery) {
console.error("Could not find gallery object for saveEdit, ID:", id);
renderGalleries();
return;
}
if (!title && !gallery.isSpacer) {
alert('Please enter a title');
return;
}
// Validate password protection
if (passwordProtected && !password) {
alert('Please enter a password when password protection is enabled');
return;
}
gallery.title = title;
gallery.metaTitle = metaTitle;
gallery.metaDescription = metaDescription;
gallery.hideMenuOnPage = hideMenu; // Save the new property
// Save password protection settings
gallery.passwordProtected = passwordProtected;
if (passwordProtected) {
gallery.password = password;
} else {
delete gallery.password; // Remove password if protection is disabled
}
if ((gallery.isPage || gallery.isIntegrated) && newSlugRaw !== null) {
const originalSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : '');
let sanitizedSlug = slugify(newSlugRaw);
if (!sanitizedSlug) {
sanitizedSlug = slugify(gallery.title);
}
if (sanitizedSlug !== originalSlug) {
const otherGalleries = galleries.filter(g => g.id !== id);
gallery.slug = ensureUniqueSlug(sanitizedSlug, otherGalleries);
gallery.url = `/${gallery.slug}`;
} else {
if (gallery.url !== `/${gallery.slug}`) {
gallery.url = `/${gallery.slug}`;
}
}
}
// Update external URL if this is an external link
if (gallery.isExternal && externalUrl) {
gallery.url = externalUrl;
}
saveGalleries();
renderGalleries();
// If the currently active gallery was just edited, re-apply menu visibility
if (gallery.id === activeGalleryId) {
const bodyEl = document.body;
if (gallery.hideMenuOnPage && !isInEditMode()) {
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page');
}
}
}
// Helper function to check if a gallery is an ancestor of another
function hasAncestor(galleryId, ancestorId) {
if (galleryId === ancestorId) return true;
const gallery = galleries.find(g => g.id === galleryId);
if (!gallery || !gallery.parentId) return false;
if (gallery.parentId === ancestorId) return true;
return hasAncestor(gallery.parentId, ancestorId);
}
function deleteGallery(id, event) {
event.stopPropagation();
if (confirm('Are you sure you want to delete this gallery?')) {
galleries = galleries.filter(g => g.id !== id);
if (activeGalleryId === id) {
activeGalleryId = null;
document.getElementById('galleryFrame').src = '';
}
saveGalleries();
renderGalleries();
}
}
function debugNestedStructure() {
console.group('Current Gallery Structure');
function printGallery(gallery, level = 0) {
const indent = ' '.repeat(level);
console.log(`${indent}${gallery.title} (ID: ${gallery.id}, Parent: ${gallery.parentId || 'none'})`);
if (gallery.children && gallery.children.length > 0) {
gallery.children.forEach(child => printGallery(child, level + 1));
}
}
// Create a tree structure for debugging
const galleryTree = createGalleryTree(galleries);
galleryTree.forEach(gallery => printGallery(gallery));
console.groupEnd();
}
function toggleHome(id, event) {
event.stopPropagation();
console.log('Setting home page to gallery:', id);
// Find gallery
const gallery = galleries.find(g => g.id === id);
if (!gallery) return;
// Check if this gallery is already set as home
const alreadyHome = gallery.isHomePage === true;
// Set all galleries to not be the home page
galleries.forEach(g => {
g.isHomePage = false;
});
// Toggle this gallery's home status - if it was already home, we're unsetting it
gallery.isHomePage = !alreadyHome;
// Save changes
saveGalleries();
// Update UI
renderGalleries();
// If this gallery is now the home page, display a message
if (gallery.isHomePage) {
const title = gallery.title || 'This page';
showSuccessMessage(`${title} is now set as the Home page`);
}
}
// Function to toggle gallery visibility
function toggleVisibility(id, event) {
event.stopPropagation();
const gallery = galleries.find(g => g.id === id);
if (gallery) {
// Toggle the visibility
gallery.visible = gallery.visible === false ? true : false;
// Save the change to server
saveGalleries();
// Update UI - important to toggle the class on the button itself
const toggle = event.currentTarget;
if (toggle) {
if (gallery.visible === false) {
toggle.classList.add('hidden');
// Update the SVG icon
toggle.querySelector('svg').innerHTML = '';
} else {
toggle.classList.remove('hidden');
// Update the SVG icon
toggle.querySelector('svg').innerHTML = '';
}
}
// Update parent row class
const listItem = document.querySelector(`li[data-id="${id}"]`);
if (listItem) {
if (gallery.visible === false) {
listItem.classList.add('hidden-gallery');
} else {
listItem.classList.remove('hidden-gallery');
}
}
// Only clear iframe if not in edit mode
if (id === activeGalleryId && gallery.visible === false && !isEditing) {
document.getElementById('galleryFrame').src = '';
}
// Re-render menus based on current layout
const currentLayout = document.body.classList.contains('menu-layout-horizontal') ? 'horizontal' :
document.body.classList.contains('menu-layout-top') ? 'top' : 'sidebar';
if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') {
window.renderHorizontalMenu();
}
// Also re-render the sidebar gallery tree to ensure hidden-gallery class is properly applied
// This is especially important when horizontal layout is active but we're viewing the sidebar in edit mode
if (typeof window.renderGalleries === 'function') {
window.renderGalleries();
} else if (typeof renderGalleries === 'function') {
renderGalleries();
}
}
}
/**
* Simplified loadGallery function that follows the same content swap pattern
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
// Enhanced loadGallery function with script cleanup
async function loadGallery(id, event) {
// Stop event propagation if provided
if (event) {
event.stopPropagation();
}
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Apply immediate hiding styles
galleryContainer.style.opacity = '0';
}
console.log('Loading gallery with ID:', id);
// Find gallery by ID
const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible
if (!gallery) {
console.warn('No gallery found with ID:', id);
if (galleryContainer) {
galleryContainer.style.opacity = '1';
}
return;
}
// Check if this is a page, if so use loadPage instead
if (gallery.isPage === true) {
// Use the loadPage function defined in this scope (or window.loadPage if you prefer)
return loadPage(id, event);
}
// Skip submenu items
if (gallery.isSubmenu) {
console.log('Gallery is submenu, skipping URL update');
toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container
return;
}
console.log('Found gallery:', gallery.title);
// CRITICAL: Set active gallery ID consistently in both global and window scope
console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')');
window.activeGalleryId = id;
activeGalleryId = id; // Ensure both variables are synchronized
// Save reference to which gallery we're loading (for later verification)
window.currentLoadingGalleryId = id;
// CRITICAL: Thorough cleanup with await to ensure completion
if (typeof window.removeGalleryScriptsWithPause === 'function') {
await window.removeGalleryScriptsWithPause();
}
// Clear all existing content from gallery container
if (!galleryContainer) {
console.error('Gallery container not found');
return;
}
// Force clear all content
galleryContainer.innerHTML = '';
// CRITICAL: Double-check active gallery ID before updating UI
if (window.activeGalleryId !== id || activeGalleryId !== id) {
console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id);
window.activeGalleryId = id;
activeGalleryId = id;
}
// Apply menu visibility based on the gallery's setting and edit mode
const bodyEl = document.body;
if (gallery.hideMenuOnPage && !isInEditMode()) { // Check if not in edit mode
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
// Update UI state using explicit window function references or local fallbacks
if (typeof window.updateActiveStates === 'function') {
window.updateActiveStates();
} else if (typeof updateActiveStates === 'function') {
updateActiveStates();
}
if (typeof window.updateMobileTitle === 'function') {
window.updateMobileTitle();
} else if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof window.closeMobileMenu === 'function') {
window.closeMobileMenu();
} else if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
// Update URL with gallery slug
if (typeof window.updateURLWithGallerySlug === 'function') {
window.updateURLWithGallerySlug(gallery);
} else if (typeof updateURLWithGallerySlug === 'function') {
updateURLWithGallerySlug(gallery);
}
// Create gallery container and load content
const neonGalleryContainer = document.createElement('div');
neonGalleryContainer.id = 'neon-gallery-container';
neonGalleryContainer.className = 'gallery-direct-content';
neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString());
neonGalleryContainer.style.width = '100%';
neonGalleryContainer.style.height = '100%';
galleryContainer.appendChild(neonGalleryContainer);
// Add a cache-busting parameter to ensure script is freshly loaded
const timestamp = Date.now();
// Set up gallery parameters
window.Parameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available
isInEditor: window.isEditing || false, // Assuming window.isEditing is available
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
isHydra: true,
galleryInstanceId: timestamp,
// Added loadedGalleryId as per your original function
loadedGalleryId: gallery.id
};
// Debug: Check what galleryOptions contains
console.error('🔍 INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL);
// Set up gallery config
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod",
layoutType: gallery.galleryOptions?.layoutType || "grid",
// Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence
// This ensures that only properties from the current gallery are included, not from previous galleries
...(gallery.galleryOptions || {}),
// CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script
// These fields are required for the gallery to determine if it should use Load Network racing
loadTxId: gallery.galleryOptions?.loadTxId || null,
loadPermaURL: gallery.galleryOptions?.loadPermaURL || null,
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
// Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread
galleryId: gallery.id,
galleryInstanceId: timestamp
};
console.error('🔍 INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL);
// Create and add the gallery script with cache-busting
// Use Worker route (avoids CDN SSL issues)
const script = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
script.src = galleryScriptsBase + 'neon-gallery-main-v260116-048.js'; // Using versioned file
script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute
script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute
console.log('Loading gallery-main script from Worker:', script.src);
script.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', script.src);
const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
script.src = proxyUrl;
script.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Added script.onload from your original function
script.onload = function() {
if (window.currentLoadingGalleryId !== gallery.id) {
console.warn('Gallery ID mismatch! Expected:', gallery.id,
'Current:', window.currentLoadingGalleryId);
}
console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`);
};
if (galleryContainer) {
setTimeout(() => {
galleryContainer.style.opacity = '1';
}, 50); // Small delay to ensure content is ready
}
console.log(`Loading fresh gallery script for: ${gallery.title}`);
document.body.appendChild(script);
}
function removeGalleryScriptsWithPause() {
return new Promise(resolve => {
console.log('Performing comprehensive gallery cleanup (v2 - refined selectors)');
// 1. Capture and clear any gallery data in localStorage (remains the same)
try {
const localStorageKeys = Object.keys(localStorage);
const galleryStorageKeys = localStorageKeys.filter(key =>
key.includes('gallery') ||
key.includes('neon') ||
key.includes('lightbox')
);
galleryStorageKeys.forEach(key => {
localStorage.removeItem(key);
});
} catch (e) {
console.warn('Error clearing localStorage:', e);
}
// @ts-ignore
window._galleryPageContext = null; // (remains the same)
// 3. Remove all gallery-specific CSS styles (remains the same)
const galleryStyles = document.querySelectorAll('style[data-gallery], link[href*="neon-gallery"]');
galleryStyles.forEach(style => {
if (style && style.parentNode) {
// console.log('Removing gallery style element'); // Console log can be verbose, optionally keep
style.parentNode.removeChild(style);
}
});
// 4. Remove lightbox elements (more specific selectors now)
// These are specific IDs and classes for lightbox components.
const lightboxComponentSelectors = [
'#neon-lightbox', // Main lightbox container by ID
'.neon-lightbox', // Alternative class for main container
'.lightbox-container',// General container class if used
// Add any other *specific* classes or IDs your lightbox system uses for its top-level elements
];
const lightboxElements = document.querySelectorAll(lightboxComponentSelectors.join(', '));
lightboxElements.forEach(lightboxEl => {
if (lightboxEl && lightboxEl.parentNode && lightboxEl !== document.body && lightboxEl !== document.documentElement) {
// console.log('Removing lightbox component:', lightboxEl.id || lightboxEl.className);
lightboxEl.parentNode.removeChild(lightboxEl);
}
});
// 5. Remove other gallery-specific DOM elements
// REMOVED: '[class*="lightbox"]', '[id*="lightbox"]' from this list
// The selector '[class*="neon-"]' is still broad but less likely to match body unless you add "neon-" class to body.
const galleryElementSelectors = [
'.neon-gallery-wrapper', '.gallery-container .fullscreen-overlay',
'.neon-gallery-modal', '.gallery-tooltip', '.neon-gallery-context-menu',
'.neon-thumbnails', '.neon-image', '.neon-caption',
'.neon-controls', '.neon-pagination',
'[class*="neon-"]', // BE CAUTIOUS: If this matches body or critical layout elements, it can cause issues.
'[data-gallery-id]', '[data-image-id]', '.image-grid', '.image-masonry'
// Ensure none of these selectors inadvertently match document.body or critical layout containers
];
const galleryElements = document.querySelectorAll(galleryElementSelectors.join(', '));
if (galleryElements.length > 0) {
galleryElements.forEach(el => {
// CRITICAL FIX: Add checks to ensure we don't remove body or html
if (el && el.parentNode && el !== document.body && el !== document.documentElement) {
// console.log('Removing gallery-specific DOM element:', el.id || el.className);
el.parentNode.removeChild(el);
} else if (el === document.body || el === document.documentElement) {
console.warn(`[CRITICAL WARNING] Attempt to remove document.body or document.documentElement blocked by selector: ${el.id || el.className}. Review your selectors.`);
}
});
}
// 6. Find and remove ALL gallery scripts (remains the same)
const scripts = document.querySelectorAll(
'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' +
'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]'
);
scripts.forEach(script => {
if (script && script.parentNode) {
// console.log('Removing script:', script.src || script.getAttribute('data-gallery-id'));
script.parentNode.removeChild(script);
}
});
// 6b. Specifically remove gallery options scripts (but NOT CSS - CSS is needed across galleries)
const optionsScripts = document.querySelectorAll(
'script[src*="galleryOptions"], script[data-options-js]'
);
optionsScripts.forEach(script => {
if (script && script.parentNode) {
console.log('Removing gallery options script:', script.src || script.getAttribute('data-options-js'));
script.parentNode.removeChild(script);
}
});
// NOTE: We intentionally do NOT remove gallery options CSS here
// The CSS is needed across galleries and doesn't cause conflicts like scripts do
setTimeout(() => {
const remainingScripts = document.querySelectorAll(
'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' +
'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]'
);
if (remainingScripts.length > 0) {
// console.warn(`[DEBUG] ${remainingScripts.length} gallery scripts still found after removal attempt.`);
}
}, 50);
// 7. Reset ALL known global state variables used by the gallery (remains the same)
const resetGlobals = [
'neonGalleryInitComplete', 'neonGalleryInitInProgress',
'neonGalleryLoaded', 'neonGalleryConfig', 'neonGalleryState',
'neonGalleryCache', 'neonGalleryImages', 'neonGallerySettings',
'neonLightbox', 'neonGalleryEventListeners', 'neonGalleryInstance',
'currentGalleryId', 'galleryData', 'imageCache', 'thumbnailCache',
];
resetGlobals.forEach(prop => {
// @ts-ignore
if (window[prop] !== undefined) {
// @ts-ignore
window[prop] = null;
}
});
// 7b. Reset gallery options panel state
// Reset optionsVisible flag if it exists on window (for cross-module access)
// @ts-ignore
if (window.optionsVisible !== undefined) {
// @ts-ignore
window.optionsVisible = false;
}
// Reset options-related globals
const optionsGlobals = [
'generateOptionsUI', 'openOptionsOverlay', 'showOptionsPanel',
'hideOptionsPanel', 'toggleOptionsOverlay', 'optionsVisible'
];
optionsGlobals.forEach(prop => {
// @ts-ignore
if (window[prop] !== undefined && typeof window[prop] !== 'function') {
// Only reset non-function properties (like flags)
// Functions should remain available
// @ts-ignore
if (prop === 'optionsVisible') {
// @ts-ignore
window[prop] = false;
}
}
});
// Short pause before resolving
setTimeout(resolve, 150);
});
}
// Fixed clearAddForm function with proper variable names
function clearAddForm() {
// Clear the title input
const titleInput = document.getElementById('galleryTitle');
if (titleInput) {
titleInput.value = '';
}
// Clear the URL input
const urlInput = document.getElementById('galleryUrl');
if (urlInput) {
urlInput.value = '';
}
// Reset the parent selection
const parentSelect = document.getElementById('galleryParent');
if (parentSelect) {
parentSelect.value = '';
}
// Reset radio buttons to external
const externalRadio = document.querySelector('input[name="galleryType"][value="external"]');
if (externalRadio) {
externalRadio.checked = true;
}
// Show URL input container - using a different variable name!
const urlInputContainer = document.getElementById('urlInputContainer');
if (urlInputContainer) {
urlInputContainer.style.display = 'block';
} else {
console.log('URL input container not found');
}
}
/**
* Ensures window.galleries is synchronized with the global galleries variable
* Call this function after any modification to gallery settings
*
* @returns {boolean} True if synchronization was successful
*/
/**
* Enhanced synchronizeGalleries function that checks ALL galleries
* not just imported ones, and ensures settings are preserved
* @returns {boolean} Success indicator
*/
function synchronizeGalleries() {
console.log('Starting enhanced gallery synchronization...');
if (typeof galleries !== 'undefined' && Array.isArray(galleries)) {
// First check if window.galleries exists and is different from galleries
const needsSync = !window.galleries ||
!Array.isArray(window.galleries) ||
window.galleries.length !== galleries.length;
// ENHANCED: Check ALL galleries for options preservation, not just imported ones
if (!needsSync && window.galleries.length > 0) {
// Find all galleries with galleryOptions
const galleriesWithOptions = galleries.filter(g =>
g.galleryOptions && Object.keys(g.galleryOptions).length > 4
);
if (galleriesWithOptions.length > 0) {
// Check ALL galleries with substantial options, not just a sample
let syncNeeded = false;
// Use for loop so we can break early if we find a mismatch
for (let i = 0; i < galleriesWithOptions.length; i++) {
const gallery = galleriesWithOptions[i];
const windowGallery = window.galleries.find(g => g.id === gallery.id);
if (windowGallery) {
// Get options depth
const galleryOptionsDepth = gallery.galleryOptions ?
Object.keys(gallery.galleryOptions).length : 0;
const windowOptionsDepth = windowGallery.galleryOptions ?
Object.keys(windowGallery.galleryOptions).length : 0;
// If options depth differs significantly, we need to sync
if (Math.abs(galleryOptionsDepth - windowOptionsDepth) > 2) {
console.log(`Gallery sync needed: Options mismatch detected for ${gallery.title}`);
console.log(`Options count: global=${galleryOptionsDepth}, window=${windowOptionsDepth}`);
syncNeeded = true;
break; // No need to check more galleries
}
// Additional check: Look for specific important gallery settings that should be preserved
// Only do this check for galleries with substantial settings
if (galleryOptionsDepth > 5 && windowOptionsDepth > 5) {
// Critical keys that indicate gallery customization
const criticalKeys = ['columns', 'spacing', 'layoutType', 'startInSingles',
'autoplaySingles', 'lightboxBgColor'];
// Check if any critical keys are missing in window.galleries
const missingKeys = criticalKeys.filter(key =>
gallery.galleryOptions[key] !== undefined &&
windowGallery.galleryOptions[key] === undefined
);
if (missingKeys.length > 0) {
console.log(`Gallery sync needed: Critical settings missing in window.galleries for ${gallery.title}`);
console.log(`Missing keys: ${missingKeys.join(', ')}`);
syncNeeded = true;
break; // No need to check more galleries
}
}
}
}
// If we detected a need to sync, do it now
if (syncNeeded) {
// Ensure we preserve rich gallery settings
window.galleries = galleries.map(gallery => {
const windowGallery = window.galleries.find(g => g.id === gallery.id);
// If both have galleryOptions, merge them to ensure all settings are preserved
if (windowGallery && windowGallery.galleryOptions && gallery.galleryOptions) {
const globalOptionsDepth = Object.keys(gallery.galleryOptions).length;
const windowOptionsDepth = Object.keys(windowGallery.galleryOptions).length;
// If window gallery has more settings, merge them with global settings
if (windowOptionsDepth > globalOptionsDepth) {
return {
...gallery,
galleryOptions: {
...windowGallery.galleryOptions, // Start with window settings
...gallery.galleryOptions // Override with any new global settings
}
};
}
}
// Otherwise use global gallery as is
return gallery;
});
console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`);
return true;
}
}
}
// If we determined sync is needed (or as a precaution), update window.galleries
if (needsSync || !window.galleries) {
window.galleries = [...galleries]; // Create a shallow copy to ensure different reference
console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`);
} else {
console.log('Gallery synchronization check: No sync needed');
}
// Verify that all galleries with rich settings have their full options preserved
const richGalleries = galleries.filter(g =>
g.galleryOptions && Object.keys(g.galleryOptions).length > 5
);
console.log(`Status: Found ${richGalleries.length} galleries with rich settings`);
// Final verification - make sure both references are identical
if (window.galleries !== galleries) {
console.warn('References not identical after sync - forcing reference equality');
window.galleries = galleries; // Force reference equality
return true;
}
return true;
} else {
console.warn('Cannot synchronize galleries: global galleries variable is undefined or not an array');
// If we have window.galleries but no global galleries, try to reverse-sync
if (window.galleries && Array.isArray(window.galleries) && window.galleries.length > 0) {
console.log('Attempting reverse sync: updating global galleries from window.galleries');
try {
// This is risky and should only happen in unusual circumstances
galleries = window.galleries;
return true;
} catch (e) {
console.error('Could not reverse-sync galleries:', e);
}
}
return false;
}
}
/**
* Loads NDJSON content from the original gallery
* @param {string} siteId - The site ID
* @param {string} pageId - The page ID of the original gallery
* @returns {Promise} - The NDJSON content or null if not found/error
*/
async function loadGalleryNDJSON(siteId, pageId) {
console.error('[NDJSON Duplication] loadGalleryNDJSON called with siteId:', siteId, 'pageId:', pageId);
if (!siteId || !pageId) {
console.error('[NDJSON Duplication] ERROR: Cannot load NDJSON - missing siteId or pageId', { siteId: siteId, pageId: pageId });
return null;
}
try {
const ndjsonFilename = `${siteId}_${pageId}.ndjson`;
const url = `https://fly.storage.tigris.dev/ns-bridge-pub/${ndjsonFilename}`;
console.error('[NDJSON Duplication] Attempting to load NDJSON from:', url);
console.error('[NDJSON Duplication] Filename:', ndjsonFilename);
const response = await fetch(url);
console.error('[NDJSON Duplication] Fetch response status:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 404) {
console.error('[NDJSON Duplication] ERROR: NDJSON file not found (404) - gallery may not have been published yet');
console.error('[NDJSON Duplication] Attempted URL:', url);
return null;
}
const errorText = await response.text().catch(() => 'Could not read error text');
console.error('[NDJSON Duplication] ERROR: Failed to load NDJSON:', response.status, response.statusText, errorText);
throw new Error(`Failed to load NDJSON: ${response.statusText}`);
}
const ndjsonContent = await response.text();
console.error('[NDJSON Duplication] SUCCESS: Loaded NDJSON, length:', ndjsonContent.length, 'characters');
console.error('[NDJSON Duplication] First 200 chars:', ndjsonContent.substring(0, 200));
return ndjsonContent;
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Exception loading NDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return null;
}
}
/**
* Duplicates and publishes NDJSON for a gallery
* @param {Object} originalGallery - The original gallery object
* @param {Object} newGallery - The new gallery object with new pageId
* @returns {Promise} - Success indicator
*/
async function duplicateGalleryNDJSON(originalGallery, newGallery) {
console.error('[NDJSON Duplication] ===== STARTING duplicateGalleryNDJSON =====');
console.error('[NDJSON Duplication] Original gallery:', {
id: originalGallery.id,
title: originalGallery.title,
pageId: originalGallery.pageId,
classicGuid: originalGallery.classicGuid,
hasGalleryOptions: !!originalGallery.galleryOptions,
manualCollectionName: originalGallery.galleryOptions?.manualCollectionName,
isClassicCollection: originalGallery.galleryOptions?.isClassicCollection
});
console.error('[NDJSON Duplication] New gallery:', {
id: newGallery.id,
title: newGallery.title,
pageId: newGallery.pageId
});
try {
// Check if this is an NDJSON-based gallery (not classic collection)
// IMPORTANT: A gallery can have a GUID-style manualCollectionName but still use NDJSON files
// Only skip if it's explicitly marked as a classic collection OR has no pageId (which means no NDJSON)
// Having a pageId means it uses NDJSON files, so we should duplicate them
const isClassicCollection = originalGallery.galleryOptions?.isClassicCollection === true;
console.error('[NDJSON Duplication] Classic collection check:', {
hasClassicGuid: !!originalGallery.classicGuid,
manualCollectionName: originalGallery.galleryOptions?.manualCollectionName,
startsWithGUID: originalGallery.galleryOptions?.manualCollectionName?.startsWith('GUID='),
isClassicCollectionFlag: originalGallery.galleryOptions?.isClassicCollection,
hasPageId: !!originalGallery.pageId,
isClassicCollection: isClassicCollection,
willSkip: isClassicCollection && !originalGallery.pageId
});
// Only skip if explicitly marked as classic collection AND has no pageId
// If it has a pageId, it uses NDJSON files and we should duplicate them
if (isClassicCollection && !originalGallery.pageId) {
console.error('[NDJSON Duplication] SKIP: Gallery is explicitly marked as classic collection with no pageId, skipping NDJSON duplication');
return true; // Not an error, just not applicable
}
// Check if original gallery has a pageId
if (!originalGallery.pageId) {
console.error('[NDJSON Duplication] SKIP: Original gallery has no pageId, skipping NDJSON duplication');
console.error('[NDJSON Duplication] Original gallery keys:', Object.keys(originalGallery));
return true; // Not an error, just not applicable
}
// Get siteId
const siteId = window.siteId || originalGallery.siteId;
console.error('[NDJSON Duplication] SiteId resolution:', {
windowSiteId: window.siteId,
gallerySiteId: originalGallery.siteId,
resolvedSiteId: siteId
});
if (!siteId) {
console.error('[NDJSON Duplication] ERROR: Cannot duplicate NDJSON - missing siteId');
console.error('[NDJSON Duplication] window.siteId:', window.siteId);
console.error('[NDJSON Duplication] originalGallery.siteId:', originalGallery.siteId);
return false;
}
// Load original NDJSON
console.error('[NDJSON Duplication] Loading original NDJSON for duplication...');
console.error('[NDJSON Duplication] Using siteId:', siteId, 'pageId:', originalGallery.pageId);
const originalNdjson = await loadGalleryNDJSON(siteId, originalGallery.pageId);
if (!originalNdjson) {
console.error('[NDJSON Duplication] ERROR: Could not load original NDJSON, gallery may not have been published yet');
console.error('[NDJSON Duplication] This means the original gallery\'s NDJSON file was not found');
return false;
}
// Publish the duplicated NDJSON with new pageId using the same endpoint as edition publisher
console.error('[NDJSON Duplication] Publishing duplicated NDJSON with new pageId:', newGallery.pageId);
const dataFileName = `${siteId}_${newGallery.pageId}.ndjson`;
console.error('[NDJSON Duplication] New filename:', dataFileName);
console.error('[NDJSON Duplication] NDJSON content length:', originalNdjson.length);
try {
// Call the publish endpoint (same as edition publisher)
console.error('[NDJSON Duplication] Calling publish endpoint: https://hydra-press-v2.fly.dev/publish');
const response = await fetch('https://hydra-press-v2.fly.dev/publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ndjsonContent: originalNdjson,
dataFileName: dataFileName
}),
});
console.error('[NDJSON Duplication] Publish response status:', response.status, response.statusText);
console.error('[NDJSON Duplication] Response headers:', Object.fromEntries(response.headers.entries()));
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
console.error('[NDJSON Duplication] ERROR: Failed to publish duplicated NDJSON:', response.status, errorText);
console.error('[NDJSON Duplication] Response status:', response.status);
console.error('[NDJSON Duplication] Response statusText:', response.statusText);
return false;
}
const result = await response.json();
console.error('[NDJSON Duplication] SUCCESS: Published duplicated NDJSON');
console.error('[NDJSON Duplication] Publish result:', JSON.stringify(result, null, 2));
console.error('[NDJSON Duplication] Publish result URLs:', result.urls);
// Note: The publish endpoint doesn't return Irys txId, so we don't update galleryOptions
// with txId/permaURL here. If Irys upload is needed, it would need to be done separately.
console.error('[NDJSON Duplication] ===== duplicateGalleryNDJSON COMPLETED SUCCESSFULLY =====');
return true;
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Exception publishing duplicated NDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return false;
}
} catch (error) {
console.error('[NDJSON Duplication] ERROR: Unexpected error in duplicateGalleryNDJSON:', error);
console.error('[NDJSON Duplication] Error message:', error.message);
console.error('[NDJSON Duplication] Error stack:', error.stack);
return false;
}
}
/**
* Creates and shows a loading overlay for gallery duplication
* @returns {Object} Object with hide function to remove the overlay
*/
function showDuplicationOverlay() {
// Remove any existing overlay first
const existingOverlay = document.getElementById('duplication-overlay');
if (existingOverlay) {
existingOverlay.remove();
}
// Add styles if not already present
if (!document.querySelector('#duplication-overlay-styles')) {
const style = document.createElement('style');
style.id = 'duplication-overlay-styles';
style.textContent = `
#duplication-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99999999;
display: flex;
align-items: center;
justify-content: center;
cursor: not-allowed;
pointer-events: all;
}
#duplication-overlay .duplication-message {
background: rgba(255, 255, 255, 0.95);
padding: 30px 50px;
font-family: Arial, sans-serif;
font-size: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
#duplication-overlay .duplication-spinner {
width: 80px;
height: 80px;
color: #444444;
}
`;
document.head.appendChild(style);
}
const overlay = document.createElement('div');
overlay.id = 'duplication-overlay';
const message = document.createElement('div');
message.className = 'duplication-message';
const spinner = document.createElement('div');
spinner.className = 'duplication-spinner';
spinner.innerHTML = `
`;
const text = document.createElement('div');
text.textContent = 'Duplicating gallery...';
text.style.textAlign = 'center';
message.appendChild(spinner);
message.appendChild(text);
overlay.appendChild(message);
document.body.appendChild(overlay);
return {
hide: function() {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
},
updateMessage: function(newMessage) {
text.textContent = newMessage;
}
};
}
/**
* Duplicates a gallery or page item.
* @param {number} id - The ID of the item to duplicate.
* @param {Event} event - The click event.
*/
async function duplicateGalleryItem(id, event) {
event.stopPropagation();
console.log(`Duplicating item with ID: ${id}`);
// Show loading overlay
const overlay = showDuplicationOverlay();
// Find the original item in the global galleries array
const originalItem = findGalleryById(galleries, id); // Use helper if available, otherwise simple find
if (!originalItem) {
console.error(`Cannot duplicate: Item with ID ${id} not found.`);
overlay.hide();
alert('Error: Could not find the item to duplicate.');
return;
}
try {
// --- Create a Deep Copy ---
// Using JSON.parse/stringify is a common way for simple object structures
// Be cautious if your objects have Dates, functions, Maps, Sets, etc.
const newItem = JSON.parse(JSON.stringify(originalItem));
// --- Modify Copied Item ---
newItem.id = Date.now() + Math.floor(Math.random() * 1000); // Generate a new unique ID
newItem.title = `${originalItem.title}-copy`; // Append "-copy" to title
// Generate new pageId if it's a page or integrated gallery
if (newItem.isPage || newItem.isIntegrated) {
newItem.pageId = generatePageId(); // Assign a new unique pageId
}
// Generate new slug and URL
const newBaseSlug = slugify(newItem.title); // Use the new title
newItem.slug = ensureUniqueSlug(newBaseSlug, galleries); // Ensure uniqueness
newItem.url = `/${newItem.slug}`; // Update URL based on new slug
// Reset children (don't duplicate children for simplicity)
newItem.children = [];
// Deep clone galleryOptions and pageElements, update pageId if needed
if (newItem.galleryOptions) {
newItem.galleryOptions = JSON.parse(JSON.stringify(newItem.galleryOptions));
// Update pageId within options if it exists and matches the old one
if (newItem.galleryOptions.pageId && newItem.galleryOptions.pageId === originalItem.pageId) {
newItem.galleryOptions.pageId = newItem.pageId;
}
}
if (newItem.pageElements) {
newItem.pageElements = JSON.parse(JSON.stringify(newItem.pageElements));
// Give new IDs to duplicated page elements
newItem.pageElements.forEach(el => {
el.id = Date.now() + Math.floor(Math.random() * 10000);
});
}
// Reset home page status
newItem.isHomePage = false;
// --- Duplicate NDJSON if applicable ---
// This must happen before saving the gallery
console.error('[Gallery Duplication] Checking if NDJSON duplication is needed...');
console.error('[Gallery Duplication] newItem.pageId:', newItem.pageId);
console.error('[Gallery Duplication] originalItem.pageId:', originalItem.pageId);
if (newItem.pageId && originalItem.pageId) {
overlay.updateMessage('Duplicating gallery files...');
console.error('[Gallery Duplication] Both galleries have pageIds, attempting to duplicate NDJSON file...');
const ndjsonDuplicated = await duplicateGalleryNDJSON(originalItem, newItem);
if (!ndjsonDuplicated) {
console.error('[Gallery Duplication] WARNING: NDJSON duplication failed, but continuing with gallery duplication');
console.error('[Gallery Duplication] User can republish the gallery later if needed');
// Don't block the duplication if NDJSON fails - user can republish later
} else {
console.error('[Gallery Duplication] SUCCESS: NDJSON duplication completed successfully');
}
} else {
console.error('[Gallery Duplication] SKIP: Skipping NDJSON duplication:', {
hasNewPageId: !!newItem.pageId,
hasOriginalPageId: !!originalItem.pageId,
reason: !newItem.pageId ? 'New gallery has no pageId' : 'Original gallery has no pageId'
});
}
overlay.updateMessage('Saving duplicated gallery...');
// --- Insert into Galleries Array ---
// Find the index of the original item
const originalIndex = galleries.findIndex(item => item.id === id);
if (originalIndex > -1) {
// Insert the new item right after the original
galleries.splice(originalIndex + 1, 0, newItem);
console.log(`Inserted duplicate "${newItem.title}" after "${originalItem.title}"`);
// Update positions for all items after the insertion point
for (let i = originalIndex + 1; i < galleries.length; i++) {
galleries[i].position = i; // Re-assign position based on new index
}
} else {
// Fallback: Add to the end if original couldn't be found (shouldn't happen)
console.warn(`Original item ${id} not found in array, adding duplicate to the end.`);
newItem.position = galleries.length;
galleries.push(newItem);
}
console.log('Rendering galleries immediately after duplication...');
renderGalleries();
// Update active states if necessary (though unlikely needed here)
updateActiveStates();
// --- Save and Refresh ---
console.log('Saving duplicated item...');
saveGalleries(null, { addedNewItem: true }) // Pass flag to trigger refresh
.then(success => {
overlay.hide(); // Hide overlay before page refresh
if (success) {
console.log('Duplicate saved successfully. Page will refresh.');
// No need to call renderGalleries() here because saveGalleries will trigger a reload.
} else {
console.error('Failed to save the duplicated item.');
alert('Error: Could not save the duplicated item.');
// Optionally try to revert the galleries array change here if save fails
}
})
.catch(error => {
overlay.hide(); // Hide overlay on error
console.error('Error saving duplicated item:', error);
alert('Error saving duplicated item.');
});
} catch (error) {
overlay.hide(); // Hide overlay on error
console.error('Error duplicating item:', error);
alert('An error occurred while duplicating the item.');
}
}
/**
* saveGalleries function that always saves FULL data
* Ensures gallery settings are preserved in both localStorage and server
*
* @param {Object} customData - Optional custom data to save instead of galleries
* @returns {Promise} - Success indicator
*/
async function saveGalleries(customData) {
if (!isAuthenticated && localStorage.getItem('hydra_is_admin') !== 'true') {
alert('You must be logged in as an admin to save changes');
return false;
}
// Prevent multiple simultaneous saves
if (window._saveInProgress) {
console.log('Save already in progress, skipping');
return false;
}
window._saveInProgress = true;
try {
// CRITICAL: Synchronize galleries before saving to ensure we use the latest data
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries synchronized before saving');
}
// Try to get a valid token from multiple sources
let token = null;
// First try the global didToken (traditional approach)
if (didToken) {
console.log('Using global didToken for save operation');
token = didToken;
}
// Next try TokenManager if available
else if (window.TokenManager && typeof window.TokenManager.getToken === 'function') {
token = window.TokenManager.getToken();
// Remove Bearer prefix if present (we'll add it later)
if (token && token.startsWith('Bearer ')) {
token = token.substring(7);
}
console.log('Using TokenManager token for save operation, length:', token ? token.length : 0);
}
// Fallback to localStorage
else if (localStorage.getItem('hydra_auth_token')) {
token = localStorage.getItem('hydra_auth_token');
console.log('Using localStorage token for save operation, length:', token.length);
}
// Last resort: try to get a fresh token
else if (magic && magic.user) {
try {
console.log('Attempting to get fresh token from Magic');
token = await magic.user.getIdToken();
console.log('Obtained fresh token from Magic:', token ? 'success' : 'failed');
} catch (e) {
console.error('Error getting token from Magic:', e);
}
}
if (!token) {
throw new Error('Could not find a valid authentication token. Please log in again.');
}
// IMPORTANT: Try to save to localStorage with isolated error handling
// This ensures localStorage failures don't prevent server saves
if (!customData) { // Only try localStorage for regular gallery saves
try {
// FIXED: Always save the FULL galleries data to localStorage
localStorage.setItem('galleries', JSON.stringify(galleries));
console.log(`Saved ${galleries.length} galleries to localStorage (FULL format with complete settings)`);
} catch (storageError) {
// Now storage errors are isolated and non-fatal
console.warn('LocalStorage quota exceeded, proceeding without local backup', storageError);
}
} else if (customData && customData.isImport) {
// Skip localStorage for import operations - data too large
console.log('Skipping localStorage for import operation - data too large for local storage');
}
// IMPORTANT: Ensure all pages have proper URLs and slugs before saving
galleries.forEach(gallery => {
if (gallery.isPage === true) {
const pageSlug = slugify(gallery.title);
// Make sure slug and URL are set correctly
if (!gallery.slug || gallery.slug === '') {
gallery.slug = pageSlug;
}
// Make sure URL points to slug
if (!gallery.url || gallery.url === '/' || !gallery.url.includes(gallery.slug)) {
gallery.url = `/${pageSlug}`;
}
}
});
// Set up the data to save - ensure we're using the complete data
let siteData;
console.log('saveGalleries: customData received:', JSON.stringify(customData));
console.log('saveGalleries: siteMetadata in customData:', customData?.siteMetadata);
console.log('saveGalleries: TITLE in customData:', customData?.siteMetadata?.title);
console.log('saveGalleries: GOOGLE ANALYTICS in customData:', customData?.siteMetadata?.googleAnalytics);
if (customData) {
// CRITICAL FIX: Ensure gallery settings are preserved when saving from sidebar
// If customData contains galleries (which it should for sidebar-initiated saves),
// merge them with any existing settings to ensure nothing is lost
if (customData.galleries && Array.isArray(customData.galleries)) {
// First, ensure we preserve all current gallery settings from the existing galleries
const existingGalleries = (typeof galleries !== 'undefined' ? galleries : window.galleries) || [];
// Create merged galleries array with preserved settings
const mergedGalleries = customData.galleries.map(gallery => {
const existingGallery = existingGalleries.find(g => g.id === gallery.id);
// If this gallery exists in the current dataset, ensure all galleryOptions are preserved
if (existingGallery && existingGallery.galleryOptions && gallery.galleryOptions) {
// Calculate settings depth
const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length;
const serverOptionsDepth = Object.keys(gallery.galleryOptions).length;
// If server has fewer settings, merge them with existing settings
if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) {
console.log(`Preserving rich settings for ${gallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`);
// Create a merged gallery with preserved settings
return {
...gallery,
galleryOptions: {
...existingGallery.galleryOptions, // Start with all existing settings
...gallery.galleryOptions // Override with any new server settings
}
};
}
}
// If no preservation needed, use server gallery as is
return gallery;
});
// Use the merged galleries in customData
siteData = {
...customData,
galleries: mergedGalleries
};
console.log(`Settings-preservation merge completed: ${mergedGalleries.length} galleries`);
console.log('saveGalleries: siteData after merge:', JSON.stringify(siteData));
console.log('saveGalleries: siteMetadata after merge:', siteData.siteMetadata);
console.log('saveGalleries: TITLE after merge:', siteData.siteMetadata?.title);
console.log('saveGalleries: GOOGLE ANALYTICS after merge:', siteData.siteMetadata?.googleAnalytics);
} else {
siteData = customData;
}
} else {
siteData = {
galleries: galleries
};
if (window.siteMetadata) {
siteData.siteMetadata = window.siteMetadata;
}
if (window.SidebarManager) {
siteData.sidebarElements = window.SidebarManager.elements;
}
}
// Get the save URL
const saveUrl = typeof getApiUrl === 'function' ?
getApiUrl('/api/save-config') :
'/api/save-config';
// Get user email from various sources
const userEmail = (window.userMetadata && window.userMetadata.email) ||
localStorage.getItem('hydra_auth_email') ||
'';
// Ensure token has proper format
const formattedToken = token.startsWith('hydra:') ? token :
(token.includes('.') ? token : `hydra:${token}`);
console.log('Making server save request...');
console.log('saveGalleries: Final siteData being sent to server:', JSON.stringify(siteData));
console.log('saveGalleries: Final siteMetadata being sent:', siteData.siteMetadata);
console.log('saveGalleries: FINAL TITLE being sent:', siteData.siteMetadata?.title);
console.log('saveGalleries: FINAL GOOGLE ANALYTICS being sent:', siteData.siteMetadata?.googleAnalytics);
// Make the request
const response = await fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${formattedToken}`,
'X-User-Email': userEmail,
'X-API-Request': 'true' // Always include this header for API requests
},
body: JSON.stringify(siteData)
});
console.log('Save response status:', response.status);
// Process the response
const responseText = await response.text();
console.log('Server response text (first 500 chars):', responseText.substring(0, 500));
let responseData;
try {
responseData = JSON.parse(responseText);
console.log('Server response parsed successfully:', responseData);
if (responseData.debug) {
console.log('Server debug info:', responseData.debug);
console.log('Server debug - requestBodyKeys:', responseData.debug.requestBodyKeys);
console.log('Server debug - hasSiteMetadata:', responseData.debug.hasSiteMetadata);
console.log('Server debug - siteMetadata:', responseData.debug.siteMetadata);
console.log('Server debug - finalSiteMetadata:', responseData.debug.finalSiteMetadata);
console.log('Server debug - googleAnalytics:', responseData.debug.googleAnalytics);
}
} catch (jsonError) {
if (response.status === 200 && responseText.trim().startsWith('')) {
responseData = { success: true };
} else {
throw new Error('Invalid response format: ' + responseText.substring(0, 100));
}
}
if (!response.ok && !responseData.success) {
throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`);
}
console.log('Configuration saved successfully');
// Store new token if provided
if (responseData.hydraToken) {
console.log('Received new hydra token from server');
// Update the global didToken
didToken = responseData.hydraToken.startsWith('hydra:') ?
responseData.hydraToken.substring(6) : responseData.hydraToken;
// Update localStorage
localStorage.setItem('hydra_auth_token', didToken);
// Update TokenManager if available
if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') {
window.TokenManager.storeToken(responseData.hydraToken, userEmail);
}
}
// Notify sidebar manager
document.dispatchEvent(new CustomEvent('sidebar-save-requested'));
// Process server response data if needed
if (responseData.galleries) {
// If server returns updated galleries data, update the global galleries variable
if (Array.isArray(responseData.galleries) && responseData.galleries.length > 0) {
// CRITICAL FIX: Before replacing galleries with server response,
// preserve any gallery settings that might be lost in the response
const updatedGalleries = responseData.galleries.map(serverGallery => {
// Find the matching gallery in our current data
const existingGallery = galleries.find(g => g.id === serverGallery.id);
// Check if we need to preserve gallery settings
if (existingGallery && existingGallery.galleryOptions && serverGallery.galleryOptions) {
// Calculate settings depth
const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length;
const serverOptionsDepth = Object.keys(serverGallery.galleryOptions).length;
// If server has fewer settings, merge them with existing settings
if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) {
console.log(`Preserving rich settings for ${serverGallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`);
// Create a merged gallery with preserved settings
return {
...serverGallery,
galleryOptions: {
...existingGallery.galleryOptions, // Start with all existing settings
...serverGallery.galleryOptions // Override with any new server settings
}
};
}
}
// If no preservation needed, use server gallery as is
return serverGallery;
});
// Update the galleries variable with our preserved data
galleries = updatedGalleries;
console.log(`Updated galleries from server response: ${galleries.length} galleries`);
// IMPORTANT: Synchronize again after receiving server response
if (typeof synchronizeGalleries === 'function') {
synchronizeGalleries();
console.log('Galleries re-synchronized after server response');
}
// Update localStorage with full data
try {
localStorage.setItem('galleries', JSON.stringify(galleries));
console.log('Updated localStorage with server gallery data (FULL format)');
} catch (e) {
console.warn('Could not update localStorage with server gallery data', e);
}
}
}
// CRITICAL FIX: Clear all sessionStorage caches after successful save
// This ensures that when users open the site in a new tab, they get fresh data
try {
console.log('Clearing sessionStorage caches after successful save...');
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && (key.includes('gallery_data_') || key.includes('neonGallery_'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
sessionStorage.removeItem(key);
console.log('Cleared sessionStorage key: ' + key);
});
console.log('Cleared ' + keysToRemove.length + ' sessionStorage cache entries');
} catch (cacheError) {
console.warn('Error clearing sessionStorage cache:', cacheError);
// Don't fail the save if cache clearing fails
}
window._saveInProgress = false;
return true;
} catch (error) {
console.error('Error saving configuration:', error);
alert('Failed to save changes: ' + error.message);
window._saveInProgress = false;
return false;
}
}
// Helper function to get API URL for preview sites
function getApiUrl(endpoint) {
// Check if we're on a preview URL
const isPreviewUrl = window.location.hostname === 'preview.neonsky.app';
if (isPreviewUrl) {
// Extract the site GUID from the path (first segment after domain)
const pathParts = window.location.pathname.split('/').filter(Boolean);
if (pathParts.length > 0) {
const siteGuid = pathParts[0];
// Include the site GUID in the API URL
return `/${siteGuid}${endpoint}`;
}
}
// Regular case - return the endpoint as is
return endpoint;
}
function updateActiveStates() {
// Get the most current active gallery ID, ensuring consistency between both variables
const currentActiveId = window.activeGalleryId || activeGalleryId;
// Ensure both variables are synchronized
window.activeGalleryId = currentActiveId;
activeGalleryId = currentActiveId;
console.log("updateActiveStates: Using active gallery ID:", currentActiveId);
// Special case for invisible home page - don't try to highlight it in the menu
const activeGallery = galleries.find(g => g.id === currentActiveId);
if (activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true) {
console.log("Active gallery is invisible home page - not highlighting in menu");
// We're not returning here, so it will still update the visibility classes
// but won't try to find an element to mark as active
}
// Fix selector issue - look for both .tree and .sidebar
document.querySelectorAll('.tree li, .sidebar li').forEach(li => {
if (!li.dataset.id) return; // Skip items without data-id attribute
const id = parseInt(li.dataset.id);
if (isNaN(id)) return; // Skip if ID is not a number
const gallery = galleries.find(g => g.id === id);
// Update active state using the most current ID
const isActive = id === currentActiveId;
// Add or remove the active class
if (isActive) {
li.classList.add('active');
console.log(`Setting active class for element: ${id} (${gallery ? gallery.title : 'Unknown'})`);
} else {
li.classList.remove('active');
}
// Update visibility class if gallery exists
if (gallery) {
li.classList.toggle('hidden-gallery', gallery.visible === false);
// Update visibility toggle icon if it exists
const toggleButton = li.querySelector('.visibility-toggle');
if (toggleButton) {
toggleButton.classList.toggle('hidden', gallery.visible === false);
}
}
});
// Verify that the active state was actually set for the current activeGalleryId
const activeElements = document.querySelectorAll('.active');
if (activeElements.length === 0 && currentActiveId) {
// Only log a warning if we're not dealing with an invisible home page
const activeGallery = galleries.find(g => g.id === currentActiveId);
if (!(activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true)) {
console.warn(`No active elements found after update for ID: ${currentActiveId}`);
}
}
// Log active items for debugging
console.log("Active gallery ID after update:", currentActiveId);
activeElements.forEach(el =>
console.log("Active element:", el.textContent.trim())
);
}
function debugGalleryStructure() {
console.group('Gallery Structure Debug');
// Log the raw data structure
console.log('Raw galleries array:', JSON.parse(JSON.stringify(galleries)));
// Log the tree structure
const treeStructure = createGalleryTree(galleries);
// Helper to print the tree
function printTree(items, level = 0) {
const indent = ' '.repeat(level);
items.forEach(item => {
console.log(`${indent}${item.title} (ID: ${item.id}, Parent: ${item.parentId || 'ROOT'})`);
if (item.children && item.children.length > 0) {
printTree(item.children, level + 1);
}
});
}
console.log('Tree structure:');
printTree(treeStructure);
// Log the DOM structure
console.log('DOM structure:');
const treeEl = document.getElementById('galleryTree');
console.log(treeEl);
console.groupEnd();
}
function createGalleryTree(galleries) {
console.log("Creating gallery tree with preservation safeguards...");
// First, create a map of galleries by id for quick lookup
const galleryMap = {};
const processedIds = new Set();
// First pass: Create clean copies without children
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return; // Skip invalid galleries
// Create a copy of the gallery object without the children field
const cleanGallery = { ...gallery };
delete cleanGallery.children;
// Initialize an empty children array
cleanGallery.children = [];
// Add to map - even if we've seen this ID before, keep the latest version
if (processedIds.has(gallery.id)) {
console.warn(`Multiple galleries with ID ${gallery.id} - using latest version`);
}
processedIds.add(gallery.id);
galleryMap[gallery.id] = cleanGallery;
});
// Second pass: Assign children to their parents
const rootGalleries = [];
// To detect already-processed children
const childrenAssigned = new Set();
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return; // Skip invalid galleries
const galleryId = gallery.id;
// Skip if this gallery isn't in our map (could be invalid)
if (!galleryMap[galleryId]) return;
// Skip if we've already assigned this gallery as a child
if (childrenAssigned.has(galleryId)) return;
if (gallery.parentId && galleryMap[gallery.parentId]) {
// This is a child gallery, but first check it doesn't create a circular reference
// Find all parents in the chain up to the root
let currentParent = galleryMap[gallery.parentId];
let safeToAdd = true;
let chainIds = [galleryId, gallery.parentId];
while (currentParent.parentId) {
// If we've seen this ID in the chain, it would create a circular reference
if (chainIds.includes(currentParent.parentId)) {
console.warn(`Would create circular reference for gallery ${gallery.title} - making it a root gallery`);
safeToAdd = false;
break;
}
chainIds.push(currentParent.parentId);
currentParent = galleryMap[currentParent.parentId];
// If parent doesn't exist in the map, break the chain
if (!currentParent) break;
}
if (safeToAdd) {
// Safe to add as child
galleryMap[gallery.parentId].children.push(galleryMap[galleryId]);
childrenAssigned.add(galleryId);
} else {
// Not safe - make it a root gallery
rootGalleries.push(galleryMap[galleryId]);
}
} else {
// This is a root gallery (no parent or parent doesn't exist)
if (!childrenAssigned.has(galleryId)) {
rootGalleries.push(galleryMap[galleryId]);
}
}
});
// Final verification
const allGalleryIds = new Set(galleries.map(g => g.id));
const treeGalleryIds = new Set();
// Function to collect all IDs in the tree
const collectIds = (items) => {
items.forEach(item => {
treeGalleryIds.add(item.id);
if (item.children && item.children.length > 0) {
collectIds(item.children);
}
});
};
collectIds(rootGalleries);
// Check if any galleries are missing from the tree
const missingIds = [];
allGalleryIds.forEach(id => {
if (!treeGalleryIds.has(id)) {
missingIds.push(id);
}
});
if (missingIds.length > 0) {
console.warn(`${missingIds.length} galleries are missing from the tree structure`);
// Add the missing galleries as root items
missingIds.forEach(id => {
const gallery = galleryMap[id];
if (gallery && !childrenAssigned.has(id)) {
console.log(`Adding missing gallery to root: ${gallery.title}`);
rootGalleries.push(gallery);
}
});
}
console.log(`Created tree with ${rootGalleries.length} root galleries`);
return rootGalleries;
}
function alignFolderStates() {
console.log("Aligning folder data states with collapsed visual appearance");
let changed = 0;
galleries.forEach(gallery => {
if ((gallery.isFolder === true || gallery.isSubmenu === true) && gallery.isCollapsed === false) {
gallery.isCollapsed = true;
changed++;
}
});
}
function renderGalleryItem(gallery, level = 0, maxLevel = 10) {
// Prevent infinite recursion by limiting depth
if (level >= maxLevel) {
console.warn(`Maximum nesting level reached for gallery: ${gallery.title}`);
return '';
}
// Add debug info to console
console.log(`Rendering gallery item: ${gallery.title}, isEditing=${isEditing}, level=${level}`);
// Skip hidden items in view mode (but not spacers)
if (!isEditing && gallery.visible === false && !gallery.isSpacer) {
return '';
}
const isPage = gallery.isPage === true;
const isFolder = gallery.isFolder === true || gallery.isSubmenu === true; // Support both folder and legacy submenu
const isSpacer = gallery.isSpacer === true;
const isExternal = gallery.isExternal === true;
// Determine if item has children
const hasChildren = isFolder || gallery.children?.length > 0;
const hasChildrenClass = hasChildren ? 'has-children' : '';
// Standard collapsed state check
const isCollapsed = gallery.isCollapsed === true;
// Simple expanded class logic - if the folder is not collapsed, add 'expanded' class
const isExpandedClass = isFolder && !isCollapsed ? 'expanded' : '';
// Add additional classes for special types
const spacerClass = isSpacer ? 'spacer-item' : '';
const externalClass = isExternal ? 'external-link' : '';
const folderClass = isFolder ? 'folder-item' : '';
const duplicateIconSvg = `
`;
// NEW: Add nesting level as a data attribute for styling
const nestingLevelAttr = `data-nesting-level="${level}"`;
// Create the gallery item HTML with enhanced nesting indicators
const html = `
`;
return html;
}
// Additional helper function to add visual indicators during drag operations
function addDragVisualFeedback() {
// Add event listener for drag operations
document.addEventListener('dragover', function(e) {
// Find the closest nested sortable list
const nearestList = e.target.closest('.nested-sortable');
if (!nearestList) return;
// Remove active-drop-target class from all lists
document.querySelectorAll('.active-drop-target').forEach(el => {
el.classList.remove('active-drop-target');
});
// Add it to the current list
nearestList.classList.add('active-drop-target');
// Calculate if this would be a nested position
// This is a simplified calculation - SortableJS has its own logic,
// but this helps provide additional visual feedback
const rect = nearestList.getBoundingClientRect();
const distanceFromLeft = e.clientX - rect.left;
// If we're close to the left edge of a nested list, add a class
if (distanceFromLeft < 30) {
nearestList.classList.add('potential-parent');
} else {
nearestList.classList.remove('potential-parent');
}
});
// Remove classes when drag ends
document.addEventListener('dragend', function() {
document.querySelectorAll('.active-drop-target, .potential-parent').forEach(el => {
el.classList.remove('active-drop-target', 'potential-parent');
});
});
}
// Make sure the addDragVisualFeedback function is called when the document is ready
document.addEventListener('DOMContentLoaded', function() {
// Initialize the visual feedback helper
addDragVisualFeedback();
// If we're in edit mode, make sure the nested sortables are initialized
if (document.body.classList.contains('edit-mode-active')) {
setTimeout(function() {
initializeNestedSortables();
}, 300);
}
});
// Update the toggleEditMode function to ensure proper initialization of sortables
const originalToggleEditMode = window.toggleEditMode;
window.toggleEditMode = function() {
originalToggleEditMode.apply(this, arguments); // Call original function
stopAutoAdvanceTimer();
const sidebar = document.querySelector('.sidebar');
const editControls = document.querySelector('.edit-controls');
if (currentMenuLayout === 'horizontal') {
if (document.body.classList.contains('edit-mode-active')) {
// In horizontal layout and edit mode is ON: show sidebar and edit controls
if(sidebar) sidebar.style.display = 'block'; // Or 'flex' if it's a flex container
if(editControls) editControls.style.display = 'flex';
// Re-render the sidebar menu for editing
renderGalleries();
} else {
// In horizontal layout and edit mode is OFF: hide sidebar
if(sidebar) sidebar.style.display = 'none';
// Re-render the horizontal menu for viewing
renderHorizontalMenu();
}
} else {
// For other layouts (sidebar, top), ensure sidebar is visible
if(sidebar) sidebar.style.display = 'block'; // Or 'flex'
}
};
function verifyGalleryStructure() {
console.log("Verifying gallery structure integrity...");
// Count number of galleries
const galleryCount = galleries.length;
console.log(`Total galleries in flat array: ${galleryCount}`);
// Create tree and count galleries in tree
const tree = createGalleryTree([...galleries]);
let treeCount = 0;
function countGalleriesInTree(items) {
items.forEach(item => {
treeCount++;
if (item.children && item.children.length > 0) {
countGalleriesInTree(item.children);
}
});
}
countGalleriesInTree(tree);
console.log(`Total galleries in tree structure: ${treeCount}`);
if (treeCount !== galleryCount) {
console.warn(`GALLERY COUNT MISMATCH: ${galleryCount} in array vs ${treeCount} in tree`);
return false;
} else {
console.log("Gallery structure is consistent");
return true;
}
}
function renderGalleries() {
console.log("Rendering galleries...");
// IMPORTANT: Synchronize with global edit state first
const sidebar = document.querySelector('.sidebar');
if (sidebar && sidebar.classList.contains('editing')) {
console.log("Edit mode detected from sidebar classes, ensuring isEditing is true");
isEditing = true;
}
try {
// Only fix circular references if absolutely necessary
const foundCircular = fixCircularReferences();
// Only create a new tree if we had to fix circular references
let galleryTree;
if (foundCircular) {
console.log("Creating fresh gallery tree after fixing circular references");
galleryTree = createGalleryTree(galleries);
} else {
console.log("Using existing structure to create gallery tree");
galleryTree = createGalleryTree(galleries);
}
// Render the tree - IMPORTANT: Add null check
const tree = document.getElementById('galleryTree');
if (!tree) {
console.warn('galleryTree element not found, cannot render galleries');
return; // Exit the function if the element doesn't exist
}
// Render the tree with maximum depth protection
tree.innerHTML = `
${galleryTree.map(gallery => renderGalleryItem(gallery, 0, 20)).join('')}
`;
// Initialize sortable for all nested sortable elements
if (isEditing) {
// Use requestAnimationFrame to ensure DOM is fully updated before initializing sortables
// This prevents drag issues when items are added and immediately dragged
requestAnimationFrame(() => {
initializeNestedSortables();
});
}
console.log("Galleries rendered successfully");
// Verify structure integrity after rendering
verifyGalleryStructure();
} catch (error) {
console.error("Error rendering galleries:", error);
}
}
function fixCircularReferences() {
console.log("Selectively checking for circular references...");
// Only process galleries that need checking:
// 1. Imported galleries (they're new and might cause issues)
// 2. Galleries with extremely deep nesting
// 3. Actual circular references
const processed = new Set();
// First pass: Identify actual circular references without modifying structure
const circularIds = new Set();
const suspiciousIds = new Set();
galleries.forEach(gallery => {
if (!gallery || !gallery.id) return;
// Skip galleries we've already processed
if (processed.has(gallery.id)) return;
// Check for self-reference - this is always wrong
if (gallery.parentId === gallery.id) {
console.warn(`Self-reference detected: Gallery ${gallery.title} (${gallery.id}) is its own parent`);
circularIds.add(gallery.id);
return;
}
// Skip galleries without parents
if (!gallery.parentId) {
processed.add(gallery.id);
return;
}
// Follow the parent chain to detect circular references
const parentChain = [gallery.id];
let currentId = gallery.parentId;
let loopCount = 0;
let foundCircular = false;
while (currentId && loopCount < 20) { // Reasonable limit to prevent infinite loops
loopCount++;
// If we've seen this ID before, it's a circular reference
if (parentChain.includes(currentId)) {
console.warn(`Circular reference detected in chain for gallery: ${gallery.title}`);
circularIds.add(gallery.id);
foundCircular = true;
break;
}
parentChain.push(currentId);
// Move up to the next parent
const parent = galleries.find(g => g.id === currentId);
currentId = parent?.parentId;
// If we can't find the parent, we can stop
if (!parent) break;
}
// If we hit our loop limit but didn't find a circular reference,
// it's suspicious but not definitely circular
if (loopCount >= 20 && !foundCircular) {
console.warn(`Suspiciously deep parent chain for gallery ${gallery.title}, marking for review`);
suspiciousIds.add(gallery.id);
}
// Mark this gallery and all its ancestors as processed
parentChain.forEach(id => processed.add(id));
});
// Second pass: Only fix the identified circular references
if (circularIds.size > 0) {
console.log(`Fixing ${circularIds.size} confirmed circular references`);
galleries.forEach(gallery => {
if (circularIds.has(gallery.id)) {
console.log(`Breaking circular reference for gallery: ${gallery.title}`);
gallery.parentId = null;
}
});
} else {
console.log("No circular references found");
}
// For suspicious galleries, don't automatically fix them
if (suspiciousIds.size > 0) {
console.warn(`Found ${suspiciousIds.size} galleries with unusually deep nesting - not fixing automatically`);
}
return circularIds.size > 0; // Return true if we fixed anything
}
// Deep linking implementation that uses the existing slug property
// Function to get the slug for a gallery
function getGallerySlug(gallery) {
// First try to use the existing slug property
if (gallery.slug) {
console.log(`Using existing gallery slug: ${gallery.slug}`);
return gallery.slug;
}
// If no slug exists, generate one from the title as fallback
if (gallery.title) {
const generatedSlug = gallery.title
.toLowerCase()
.replace(/[^ws-]/g, '')
.replace(/s+/g, '-')
.replace(/--+/g, '-')
.trim();
console.log(`Generated slug from title: ${generatedSlug}`);
return generatedSlug || 'gallery';
}
// Default fallback
console.warn('Gallery has no slug or title, using default');
return 'gallery';
}
// Function to update the URL when a gallery is loaded
// Update the URL when a gallery is loaded
function updateURLWithGallerySlug(gallery) {
if (!gallery) return;
// Get the slug for this gallery/page
let slug = gallery.slug;
if (!slug && gallery.title) {
slug = gallery.title.toLowerCase().replace(/s+/g, '-').replace(/[^w-]+/g, '').replace(/--+/g, '-').trim();
}
// Check if we're in preview mode (add this new code)
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
// Check if current history state already has this galleryId
const currentState = window.history.state;
const currentPath = window.location.pathname;
const isAlreadyCurrentPage = currentState && currentState.galleryId === gallery.id &&
((isPreviewMode && currentPath.includes(slug)) || (!isPreviewMode && currentPath === '/' + slug));
if (isPreviewMode) {
// Extract GUID from current URL path - first segment after domain
const pathParts = window.location.pathname.split('/').filter(Boolean);
const siteGuid = pathParts[0];
// Create history entry if we have a valid GUID and slug, and it's not already the current state
if (siteGuid && slug) {
const targetPath = '/' + siteGuid + '/' + slug;
// Only push if URL is different OR if state doesn't match (allows multiple navigations)
if (currentPath !== targetPath || !isAlreadyCurrentPage) {
console.log('🔍 [updateURLWithGallerySlug] Updating URL to ' + targetPath + ' (preview mode), galleryId: ' + gallery.id);
// Update browser URL without reloading, preserving the GUID
window.history.pushState(
{ galleryId: gallery.id },
gallery.title || 'Gallery',
targetPath
);
// Update page title
document.title = (gallery.title || 'Gallery') + ' - ' + window.location.hostname;
} else {
console.log('🔍 [updateURLWithGallerySlug] URL and state already match, skipping history update (preview mode)');
}
}
return; // Exit early, we've handled the preview case
}
// Regular (non-preview) URL handling
if (slug) {
const targetPath = '/' + slug;
// Only push if URL is different OR if state doesn't match (allows multiple navigations)
if (currentPath !== targetPath || !isAlreadyCurrentPage) {
console.log('🔍 [updateURLWithGallerySlug] Updating URL to ' + targetPath + ', galleryId: ' + gallery.id);
window.history.pushState(
{ galleryId: gallery.id },
gallery.title || 'Gallery',
targetPath
);
document.title = (gallery.title || 'Gallery') + ' - ' + window.location.hostname;
} else {
console.log('🔍 [updateURLWithGallerySlug] URL and state already match, skipping history update');
}
}
}
function loadHomePage() {
// Find a gallery marked as home page (regardless of visibility)
const homePage = galleries.find(gallery => gallery.isHomePage === true);
if (homePage) {
console.log(`Loading home page: ${homePage.title} (Visible: ${homePage.visible !== false})`);
// Update active gallery ID
activeGalleryId = homePage.id;
// Add a special flag to indicate we're loading the home page
// This allows us to bypass visibility checks
window._loadingHomePage = true;
// Determine if this is a page or gallery and load appropriately
if (homePage.isPage) {
loadPage(homePage.id);
} else {
loadGallery(homePage.id);
}
// Clear the flag after loading
setTimeout(() => {
window._loadingHomePage = false;
}, 100);
// Update UI state
updateActiveStates();
updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
return true;
}
console.log('No home page defined');
return false;
}
// 4. Update the handleURLNavigation function to check for home page
// Find the existing handleURLNavigation function and modify as follows:
// In index.ts
function handleURLNavigation() {
const path = window.location.pathname.substring(1); // Remove leading slash
const isPreviewMode = window.location.hostname === 'preview.neonsky.app';
let slug = path;
let siteGuid = null;
if (isPreviewMode && path) {
const pathParts = path.split('/');
if (pathParts.length >= 1) {
siteGuid = pathParts[0]; // Path could be just GUID/ or GUID/slug
}
if (pathParts.length >= 2) {
slug = pathParts[1]; // Slug is the part after GUID
console.log(`Preview URL detected, using slug: "${slug}" under GUID: "${siteGuid}"`);
} else {
// Only GUID is present, or path is empty after GUID
slug = ''; // Treat as root/home for the given GUID
console.log(`Preview URL with only GUID: "${siteGuid}", checking for home page or default.`);
}
}
if (!slug) { // Handles root path ('/') or preview URL with only GUID ('/GUID/')
console.log("No specific slug in path (root or GUID-only preview), checking for home page.");
if (!loadHomePage() && galleries.length > 0) {
const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log("No home page, loading first visible item:", firstVisibleGallery.title);
if (firstVisibleGallery.isPage) {
loadPage(firstVisibleGallery.id);
} else {
loadGallery(firstVisibleGallery.id);
}
} else {
console.log("No home page and no visible items to load for this path.");
}
}
} else {
console.log(`Handling URL navigation for slug: "${slug}"` + (isPreviewMode ? ` (Preview GUID: ${siteGuid})` : ""));
// findGalleryByPath should be able to find the item using the slug,
// handling preview mode internally if necessary or by being passed the correct slug.
const matchingGallery = findGalleryByPath(slug, isPreviewMode, siteGuid);
if (matchingGallery) {
console.log('Loading content from URL: "' + matchingGallery.title + '" (ID: ' + matchingGallery.id + ')');
console.log('🔍 [handleURLNavigation] Gallery properties - isPage:', matchingGallery.isPage, 'pageId:', matchingGallery.pageId, 'has pageElements:', !!(matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements)));
if (matchingGallery.isSubmenu) {
console.log('Gallery is a submenu, skipping direct load. Parent expansion might be needed.');
// Potentially, you might want to find and expand its parent here if the UI supports it.
} else {
// Set activeGalleryId *before* calling loadPage/loadGallery
activeGalleryId = matchingGallery.id;
window.activeGalleryId = matchingGallery.id;
// Determine if this is a page by checking multiple indicators
// Check isPage flag, pageId, or pageElements array
const isPage = matchingGallery.isPage === true ||
(matchingGallery.pageId && matchingGallery.pageId.startsWith('page_')) ||
(matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements) && matchingGallery.pageElements.length > 0);
if (isPage) {
console.log('🔍 [handleURLNavigation] Detected as PAGE, loading page: "' + matchingGallery.title + '"');
loadPage(matchingGallery.id);
} else {
console.log('🔍 [handleURLNavigation] Detected as GALLERY, loading gallery: "' + matchingGallery.title + '"');
loadGallery(matchingGallery.id); // This is async and should be awaited if subsequent logic depends on its completion.
}
}
} else {
console.log(`No matching gallery found for slug: "${slug}". Checking for home page as fallback.`);
if (!loadHomePage()) { // Try loading home page
const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu);
if (firstVisibleGallery) {
console.log(`Loading first visible gallery instead: "${firstVisibleGallery.title}"`);
if (firstVisibleGallery.isPage) {
loadPage(firstVisibleGallery.id);
} else {
loadGallery(firstVisibleGallery.id);
}
} else {
console.log("No matching gallery, no home page, and no visible items to load.");
// Optionally, display a 404 message in the gallery-container
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '
Page not found.
';
galleryContainer.style.opacity = '1';
}
}
}
}
}
// Update UI states after initiating load.
// These functions should ideally use the now-set activeGalleryId.
if (typeof updateActiveStates === 'function') updateActiveStates(); // For sidebar/tree
if (typeof updateMobileTitle === 'function') updateMobileTitle(); // For mobile header
// **** ADDED FOR DEEP LINKING HORIZONTAL MENU ****
// Determine current layout (it might not be set by MenuStyleCustomizer yet on direct load)
let currentLayout = 'sidebar'; // Default assumption
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout;
} else {
// Fallback: check body class if MenuStyleCustomizer hasn't initialized settings
if (document.body.classList.contains('menu-layout-horizontal')) {
currentLayout = 'horizontal';
}
}
console.log(`handleURLNavigation: Determined current layout as: ${currentLayout}`);
if (currentLayout === 'horizontal') {
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("handleURLNavigation: Explicitly calling updateActiveStatesHorizontal for horizontal layout on direct navigation.");
// It's possible menu items aren't rendered yet by renderHorizontalMenu if this is a very early call.
// A small delay might be needed, or ensure renderHorizontalMenu has run.
// For now, call it directly. If items aren't there, it won't do anything harmful.
setTimeout(() => { // Add a slight delay to allow menu rendering
updateActiveStatesHorizontal();
}, 100); // Adjust delay if needed, or find a more robust way to ensure menu is rendered.
} else {
console.warn("handleURLNavigation: updateActiveStatesHorizontal function not found for horizontal layout.");
}
}
}
/**
* Enhanced loadPage function that properly cleans up gallery content
* @param {number} id - The page ID
* @param {Event} event - Optional event object
*/
window.loadPage = function(id, event) {
if (event) {
event.preventDefault();
event.stopPropagation();
const now = Date.now();
const lastCallTime = window._lastPageLoadTime || 0;
window._lastPageLoadTime = now;
if (now - lastCallTime < 100) {
console.log('Ignoring duplicate loadPage call');
return;
}
}
console.error('🔍 [loadPage] Loading page with gallery ID:', id);
stopAutoAdvanceTimer();
let galleriesData = window.galleries || galleries;
const gallery = findGalleryById(galleriesData, id);
if (!gallery) {
console.warn('No gallery found with ID:', id, 'for page load.');
return;
}
if (typeof window.removeGalleryScriptsWithPause === 'function') {
window.removeGalleryScriptsWithPause();
}
window.activeGalleryId = id;
activeGalleryId = id;
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Clear the container but ensure it's visible
galleryContainer.innerHTML = '';
galleryContainer.style.display = 'block';
galleryContainer.style.visibility = 'visible';
galleryContainer.style.opacity = '1';
console.log('🔍 [loadPage] Cleared and ensured gallery-container is visible');
} else {
console.error('🔍 [loadPage] gallery-container not found!');
}
if (!gallery.pageId) gallery.pageId = `page_${id}`;
if (!gallery.isPage) gallery.isPage = true;
if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {};
window._pageIdToGalleryId[gallery.pageId] = id;
if (gallery.pageElements && Array.isArray(gallery.pageElements)) {
if (window.PageManager && window.PageManager.elements) {
window.PageManager.elements[gallery.pageId] = [...gallery.pageElements];
}
} else if (window.PageManager && window.PageManager.elements &&
window.PageManager.elements[gallery.pageId] &&
window.PageManager.elements[gallery.pageId].length > 0) {
gallery.pageElements = [...window.PageManager.elements[gallery.pageId]];
}
if (window.PageManager && typeof window.PageManager.loadPage === 'function') {
try {
console.error('🔍 [loadPage] Calling PageManager.loadPage for pageId:', gallery.pageId);
console.error('🔍 [loadPage] Gallery container exists:', !!galleryContainer, 'Container HTML length:', galleryContainer ? galleryContainer.innerHTML.length : 0);
console.error('🔍 [loadPage] Page elements count:', gallery.pageElements ? gallery.pageElements.length : 0);
console.error('🔍 [loadPage] PageManager.elements[' + gallery.pageId + '] exists:', !!(window.PageManager.elements && window.PageManager.elements[gallery.pageId]));
// Small delay to ensure DOM is ready, especially when navigating back via history
setTimeout(() => {
// Verify container still exists
const containerCheck = document.querySelector('.gallery-container');
console.error('🔍 [loadPage] Container check before loadPage:', !!containerCheck, 'Same as original:', containerCheck === galleryContainer);
// PageManager.loadPage is async - we need to properly handle it
try {
const loadPromise = window.PageManager.loadPage(gallery.pageId);
console.error('🔍 [loadPage] PageManager.loadPage called, returned:', typeof loadPromise, 'is promise:', loadPromise && typeof loadPromise.then === 'function');
if (loadPromise && typeof loadPromise.then === 'function') {
loadPromise.then(() => {
console.error('🔍 [loadPage] PageManager.loadPage promise resolved');
// Ensure container visibility after PageManager has initialized
if (galleryContainer) {
const pageContainer = galleryContainer.querySelector('.page-container');
if (pageContainer) {
pageContainer.style.display = 'block';
pageContainer.style.visibility = 'visible';
pageContainer.style.opacity = '1';
const elementCount = pageContainer.querySelectorAll('.page-element').length;
console.error('🔍 [loadPage] Page container found and made visible, has', elementCount, 'elements');
if (elementCount === 0) {
console.error('🔍 [loadPage] WARNING: Page container exists but has no elements!');
console.error('🔍 [loadPage] PageManager.elements[' + gallery.pageId + ']:', window.PageManager.elements[gallery.pageId]);
}
} else {
console.error('❌ [loadPage] Page container not found after PageManager.loadPage');
console.error('❌ [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500));
console.error('❌ [loadPage] Gallery container children:', galleryContainer.children.length);
}
} else {
console.error('❌ [loadPage] Gallery container is null after PageManager.loadPage');
}
}).catch((error) => {
console.error('❌ [loadPage] Error in PageManager.loadPage promise:', error);
console.error('❌ [loadPage] Error stack:', error.stack);
});
} else {
// If loadPage doesn't return a promise, check after a delay
console.error('🔍 [loadPage] loadPage did not return a promise, checking after delay');
setTimeout(() => {
if (galleryContainer) {
const pageContainer = galleryContainer.querySelector('.page-container');
if (pageContainer) {
pageContainer.style.display = 'block';
pageContainer.style.visibility = 'visible';
pageContainer.style.opacity = '1';
console.error('🔍 [loadPage] Ensured page-container is visible (non-promise path)');
} else {
console.error('❌ [loadPage] Page container not found (non-promise path)');
console.error('❌ [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500));
}
}
}, 200);
}
} catch (loadError) {
console.error('❌ [loadPage] Exception calling PageManager.loadPage:', loadError);
console.error('❌ [loadPage] Error stack:', loadError.stack);
}
const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false;
if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') {
window.PageManager.setEditMode(isCurrentlyEditing);
}
}, 50); // Small delay to ensure DOM is ready
} catch (error) {
console.error('❌ [loadPage] Error in loadPage function:', error);
console.error('❌ [loadPage] Error stack:', error.stack);
}
} else {
console.error('❌ [loadPage] PageManager not found or loadPage method not available');
console.error('❌ [loadPage] window.PageManager:', !!window.PageManager);
console.error('❌ [loadPage] typeof loadPage:', window.PageManager ? typeof window.PageManager.loadPage : 'N/A');
}
// Apply menu visibility
const bodyEl = document.body;
if (gallery.hideMenuOnPage && !isInEditMode()) { // Check if not in edit mode
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
if (typeof closeMobileMenu === 'function') closeMobileMenu();
// Close the gallery options panel when navigating to a different page
if (typeof window.closeOptionsPanel === 'function') {
window.closeOptionsPanel();
}
if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery);
}
/**
* Ensures all required gallery styles are loaded
*/
function ensureGalleryStylesLoaded() {
// Use Worker route for gallery CSS (avoids CDN SSL issues)
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
const requiredStyles = [
{ id: 'neon-gallery-css', href: galleryScriptsBase + 'neon-gallery-hp-hydra-v260116-048.css' },
{ id: 'quill-css', href: 'https://cdn.quilljs.com/1.3.6/quill.snow.css' }
];
console.log('Loading gallery CSS from Worker:', galleryScriptsBase + 'neon-gallery-hp-hydra-v260116-048.css');
requiredStyles.forEach(style => {
if (!document.getElementById(style.id)) {
const link = document.createElement('link');
link.id = style.id;
link.rel = 'stylesheet';
link.href = style.href;
if (style.href.includes('cdn.neonsky.app')) {
link.onerror = function() {
console.error('❌ Gallery CSS failed, retrying via proxy:', link.href);
// Set flag if not already set
if (!window.useCdnProxy) {
window.useCdnProxy = true;
console.error('🚩 CDN_PROXY_FLAG SET: Gallery CSS failed, using proxy for all subsequent requests');
}
const proxyUrl = link.href.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
link.href = proxyUrl;
link.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
link.href = link.href.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
}
document.head.appendChild(link);
console.log(`Added gallery style: ${style.id}`);
}
});
}
/**
* Ensures all required gallery scripts are loaded
* @returns {Promise} A promise that resolves when all scripts are loaded
*/
function ensureGalleryScriptsLoaded() {
const requiredScripts = [
{ id: 'crypto-js', src: 'https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js' },
{ id: 'quill-js', src: 'https://cdn.quilljs.com/1.3.6/quill.min.js' },
{ id: 'quill-integration', src: 'https://cdn.neonsky.app/quill-integration-v260116-048.js' }
];
const promises = requiredScripts.map(script => {
return new Promise((resolve, reject) => {
// If script is already loaded, resolve immediately
if (document.getElementById(script.id)) {
resolve();
return;
}
// Create and load the script
const scriptElement = document.createElement('script');
scriptElement.id = script.id;
scriptElement.src = script.src;
scriptElement.onload = resolve;
scriptElement.onerror = () => reject(new Error(`Failed to load script: ${script.src}`));
document.head.appendChild(scriptElement);
});
});
return Promise.all(promises);
}
/**
* Creates a clean gallery context without using an iframe
* @param {Object} gallery - The gallery object
* @param {HTMLElement} container - The container to load the gallery into
*/
function createGalleryContext(gallery, container) {
console.log('Creating gallery context for:', gallery.title);
// CRITICAL: Ensure gallery CSS is loaded BEFORE loading the gallery script
// This ensures styles are available even if NDJSON fails and falls back to classic JSON
ensureGalleryStylesLoaded();
// Generate the page alias safely
const pageAlias = gallery.slug || gallery.title.toLowerCase().replace(/s+/g, '-');
// Get our gallery options
const galleryOptions = gallery.galleryOptions || {};
// Get token data
let authToken = null;
// First try TokenManager if available
if (window.TokenManager && typeof window.TokenManager.getToken === 'function') {
authToken = window.TokenManager.getToken();
}
// Then try global token
else if (window.didToken) {
authToken = window.didToken;
}
// Set up Parameters for the gallery
const galleryParameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: pageAlias,
isInEditor: window.isEditing || false,
siteId: window.siteId || '',
isHydra: true
};
// If we have a token, add it
if (authToken) {
galleryParameters.hydraAuthToken = authToken;
}
// Preserve original window.Parameters
const originalParameters = window.Parameters;
// REMOVED: Loading indicator - no longer showing the loading animation
// Load the main gallery script - Use Worker route (avoids CDN SSL issues)
const galleryScript = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
galleryScript.src = galleryScriptsBase + 'neon-gallery-main-v260116-048.js';
console.log('Loading gallery-main script from Worker:', galleryScript.src);
galleryScript.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', galleryScript.src);
const proxyUrl = galleryScript.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
galleryScript.src = proxyUrl;
galleryScript.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
galleryScript.src = galleryScript.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Setup the gallery configuration
const neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: "mod",
layoutType: "grid",
...galleryOptions,
siteId: window.siteId || ''
};
// Execute in a safe way that minimizes global namespace pollution
try {
// Set up the global variables needed by the gallery script
window.Parameters = galleryParameters;
window.neonGalleryConfig = neonGalleryConfig;
// Monitor for script load/error
galleryScript.onload = () => {
console.log('Gallery script loaded successfully for:', gallery.title);
// REMOVED: Don't need to remove the loading indicator since we don't add it
};
galleryScript.onerror = () => {
console.error('Failed to load gallery script for:', gallery.title);
//container.innerHTML = '
Error loading gallery. Please try again later.
';
// Restore original parameters
window.Parameters = originalParameters;
};
// Add the script to load the gallery
document.body.appendChild(galleryScript);
// Set up a cleanup function that will be called when another gallery is loaded
container.cleanup = () => {
// Restore original parameters when cleaning up
window.Parameters = originalParameters;
// Remove the gallery script to prevent conflicts
galleryScript.remove();
// Clear main gallery container
if (container.parentNode) {
container.innerHTML = '';
}
};
} catch (error) {
console.error('Error creating gallery context:', error);
//container.innerHTML = '
Error initializing gallery. Please try again later.
';
// Restore original parameters
window.Parameters = originalParameters;
}
}
/**
* Only used for the PageManager's initializeDOM - ensures clean content swap
*/
function cleanGalleryContainer() {
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '';
}
}
/**
* Initializes the page using the PageManager
* To be used by PageManager.initializeDOM
*/
function initPageContent(pageId) {
// First clear the container completely
cleanGalleryContainer();
// Then load the page content
if (window.PageManager && window.PageManager.loadPage) {
window.PageManager.loadPage(pageId);
}
}
/**
* Cleanup gallery content when changing between galleries or pages
*/
function cleanupGalleryContent() {
// Get the gallery container
const galleryContainer = document.querySelector('.gallery-container');
if (!galleryContainer) return;
// Find the direct gallery content
const directContent = galleryContainer.querySelector('.gallery-direct-content');
if (directContent && typeof directContent.cleanup === 'function') {
// Call the cleanup function
directContent.cleanup();
}
// Clear the container
galleryContainer.innerHTML = '';
}
/**
* Simplified loadGallery function that follows the same content swap pattern
* @param {number} id - The gallery ID
* @param {Event} event - Optional event object
*/
// Override loadGallery to update URL
window.loadGallery = async function(id, event) {
// Stop event propagation if provided
if (event) {
event.stopPropagation();
}
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) {
// Apply immediate hiding styles
galleryContainer.style.opacity = '0';
}
console.log('Loading gallery with ID:', id);
stopAutoAdvanceTimer();
// Find gallery by ID
const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible
if (!gallery) {
console.warn('No gallery found with ID:', id);
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container if gallery not found
return;
}
// Check if this is a page, if so use loadPage instead
if (gallery.isPage === true) {
// Use the loadPage function defined in this scope (or window.loadPage if you prefer)
return loadPage(id, event);
}
// Skip submenu items
if (gallery.isSubmenu) {
console.log('Gallery is submenu, skipping URL update');
toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available
if (galleryContainer) {
galleryContainer.style.opacity = '1';
} // Show container
return;
}
console.log('Found gallery:', gallery.title);
// CRITICAL: Set active gallery ID consistently in both global and window scope
console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')');
window.activeGalleryId = id;
activeGalleryId = id; // Ensure both variables are synchronized
// Save reference to which gallery we're loading (for later verification)
window.currentLoadingGalleryId = id;
// CRITICAL: Thorough cleanup with await to ensure completion
if (typeof window.removeGalleryScriptsWithPause === 'function') {
await window.removeGalleryScriptsWithPause();
}
// Clear all existing content from gallery container
if (!galleryContainer) {
console.error('Gallery container not found');
return;
}
// Force clear all content
galleryContainer.innerHTML = '';
// CRITICAL: Double-check active gallery ID before updating UI
if (window.activeGalleryId !== id || activeGalleryId !== id) {
console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id);
window.activeGalleryId = id;
activeGalleryId = id;
}
// Apply menu visibility based on the gallery's setting and edit mode
const bodyEl = document.body;
if (gallery.hideMenuOnPage && !isInEditMode()) { // Check if not in edit mode
bodyEl.classList.add('menu-hidden-on-page');
} else {
bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden
}
// Update UI state using explicit window function references or local fallbacks
if (typeof window.updateActiveStates === 'function') {
window.updateActiveStates();
} else if (typeof updateActiveStates === 'function') {
updateActiveStates();
}
if (typeof window.updateMobileTitle === 'function') {
window.updateMobileTitle();
} else if (typeof updateMobileTitle === 'function') {
updateMobileTitle();
}
if (typeof window.closeMobileMenu === 'function') {
window.closeMobileMenu();
} else if (typeof closeMobileMenu === 'function') {
closeMobileMenu();
}
// Update URL with gallery slug
if (typeof window.updateURLWithGallerySlug === 'function') {
window.updateURLWithGallerySlug(gallery);
} else if (typeof updateURLWithGallerySlug === 'function') {
updateURLWithGallerySlug(gallery);
}
// Create gallery container and load content
const neonGalleryContainer = document.createElement('div');
neonGalleryContainer.id = 'neon-gallery-container';
neonGalleryContainer.className = 'gallery-direct-content';
neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString());
neonGalleryContainer.style.width = '100%';
neonGalleryContainer.style.height = '100%';
galleryContainer.appendChild(neonGalleryContainer);
// Add a cache-busting parameter to ensure script is freshly loaded
const timestamp = Date.now();
// Set up gallery parameters
window.Parameters = {
SiteAlias: window.location.hostname,
InitialPageUuid: gallery.pageId,
InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available
isInEditor: window.isEditing || false, // Assuming window.isEditing is available
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
isHydra: true,
galleryInstanceId: timestamp,
// Added loadedGalleryId as per your original function
loadedGalleryId: gallery.id
};
// Debug: Check what galleryOptions contains
console.error('🔍 INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL);
// Set up gallery config
window.neonGalleryConfig = {
useData: true,
useCDN: true,
version: 'live',
manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod",
layoutType: gallery.galleryOptions?.layoutType || "grid",
// Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence
// This ensures that only properties from the current gallery are included, not from previous galleries
...(gallery.galleryOptions || {}),
// CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script
// These fields are required for the gallery to determine if it should use Load Network racing
loadTxId: gallery.galleryOptions?.loadTxId || null,
loadPermaURL: gallery.galleryOptions?.loadPermaURL || null,
siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available
// Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread
galleryId: gallery.id,
galleryInstanceId: timestamp
};
console.error('🔍 INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL);
// Create and add the gallery script with cache-busting
// Use Worker route (avoids CDN SSL issues)
const script = document.createElement('script');
const galleryScriptsBase = window.location.origin + '/gallery-scripts/';
script.src = galleryScriptsBase + 'neon-gallery-main-v260116-048.js'; // Using versioned file
script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute
script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute
console.log('Loading gallery-main script from Worker:', script.src);
script.onerror = function() {
console.error('❌ Gallery script failed, retrying via proxy:', script.src);
const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/');
script.src = proxyUrl;
script.onerror = function() {
console.error('❌ Proxy failed, trying storage:', proxyUrl);
script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app');
};
};
// Added script.onload from your original function
script.onload = function() {
if (window.currentLoadingGalleryId !== gallery.id) {
console.warn('Gallery ID mismatch! Expected:', gallery.id,
'Current:', window.currentLoadingGalleryId);
}
console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`);
};
if (galleryContainer) {
setTimeout(() => {
galleryContainer.style.opacity = '1';
}, 50); // Small delay to ensure content is ready
}
console.log(`Loading fresh gallery script for: ${gallery.title}`);
document.body.appendChild(script);
}
// Function to ensure gallery container is visible
function ensureGalleryVisibility() {
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer && galleryContainer.style.opacity === '0') {
console.log('ensureGalleryVisibility: Setting gallery container opacity to 1');
galleryContainer.style.opacity = '1';
}
}
// Handle browser back/forward navigation
window.addEventListener('popstate', async function(event) { // Make it async
console.error('🔍 [Popstate] Event triggered. State:', event.state, 'Current URL:', window.location.pathname);
let galleryIdToLoad = null;
let galleryToLoad = null;
// First, try to get galleryId from event.state
if (event.state && event.state.galleryId) {
galleryIdToLoad = event.state.galleryId;
console.error('🔍 [Popstate] Found galleryId in event.state:', galleryIdToLoad);
// Ensure galleries array is available and use findGalleryById helper
const currentGalleries = window.galleries || galleries || [];
galleryToLoad = findGalleryById(currentGalleries, galleryIdToLoad);
}
// If no gallery found from state, try to get it from the URL
if (!galleryToLoad) {
console.error('🔍 [Popstate] No galleryId in event.state or gallery not found, reading from URL:', window.location.pathname);
if (typeof handleURLNavigation === 'function') {
// handleURLNavigation will parse the URL and load the appropriate page/gallery
console.error('🔍 [Popstate] Calling handleURLNavigation to parse URL and load content');
handleURLNavigation(); // This function should handle its own UI updates including active states.
// After handleURLNavigation, update UI states
setTimeout(() => {
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
// Ensure gallery visibility after popstate navigation
setTimeout(ensureGalleryVisibility, 1000);
let currentLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout;
} else if (document.body.classList.contains('menu-layout-horizontal')) {
currentLayout = 'horizontal';
}
if (currentLayout === 'horizontal') {
if (typeof updateActiveStatesHorizontal === 'function') {
console.error("🔍 [Popstate] Calling updateActiveStatesHorizontal for horizontal layout with a delay.");
setTimeout(() => {
console.error("[Delayed Update from Popstate] Calling updateActiveStatesHorizontal.");
updateActiveStatesHorizontal();
}, 150);
}
}
}, 100);
return; // Exit because handleURLNavigation will manage updates.
}
}
// If we have a gallery from state, load it
let contentLoaded = false;
if (galleryToLoad) {
console.error('🔍 [Popstate] Found gallery in history: "' + galleryToLoad.title + '" (ID: ' + galleryToLoad.id + ')');
console.error('🔍 [Popstate] Gallery properties - isPage:', galleryToLoad.isPage, 'pageId:', galleryToLoad.pageId, 'has pageElements:', !!(galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements)));
activeGalleryId = galleryToLoad.id;
window.activeGalleryId = galleryToLoad.id;
const editModeActive = typeof isEditing !== 'undefined' ? isEditing : (typeof window.isInEditMode === 'function' ? window.isInEditMode() : false);
// When navigating back via browser history, we should load the content regardless of visibility
// The URL itself indicates the user should see this content. Visibility checks are for menu display, not navigation.
// Only skip if it's explicitly marked as not visible AND we're not in edit mode AND it's not a page
const shouldLoad = galleryToLoad.visible !== false || editModeActive || galleryToLoad.isPage === true;
console.error('🔍 [Popstate] Visibility check - visible:', galleryToLoad.visible, 'editModeActive:', editModeActive, 'isPage:', galleryToLoad.isPage, 'shouldLoad:', shouldLoad);
if (shouldLoad) {
// Determine if this is a page by checking multiple indicators
// Check isPage flag, pageId, or pageElements array
const isPage = galleryToLoad.isPage === true ||
(galleryToLoad.pageId && galleryToLoad.pageId.startsWith('page_')) ||
(galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements) && galleryToLoad.pageElements.length > 0);
if (isPage) {
console.error('🔍 [Popstate] Detected as PAGE, loading page "' + galleryToLoad.title + '"');
if (typeof loadPage === 'function') {
console.error('🔍 [Popstate] Calling loadPage for ID:', galleryToLoad.id);
loadPage(galleryToLoad.id);
contentLoaded = true;
} else {
console.error('❌ [Popstate] loadPage function not available');
}
} else {
console.error('🔍 [Popstate] Detected as GALLERY, loading gallery "' + galleryToLoad.title + '"');
if (typeof loadGallery === 'function') {
await loadGallery(galleryToLoad.id); // Await here
contentLoaded = true;
} else {
console.error('❌ [Popstate] loadGallery function not available');
}
}
} else {
console.error('🔍 [Popstate] Gallery "' + galleryToLoad.title + '" is not visible and not in edit mode. Clearing content.');
const galleryContainer = document.querySelector('.gallery-container');
if (galleryContainer) galleryContainer.innerHTML = '';
// activeGalleryId = null; // Keep activeGalleryId to reflect URL, even if content not shown.
}
} else {
console.error('❌ [Popstate] No gallery found and handleURLNavigation not available');
}
// Update UI states after loading (or attempting to load) content
if (typeof updateActiveStates === 'function') updateActiveStates();
if (typeof updateMobileTitle === 'function') updateMobileTitle();
// Ensure gallery visibility after popstate navigation
setTimeout(ensureGalleryVisibility, 1000);
let currentLayout = 'sidebar';
if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) {
currentLayout = window.MenuStyleCustomizer.settings.menuLayout;
} else if (document.body.classList.contains('menu-layout-horizontal')) {
currentLayout = 'horizontal';
}
if (currentLayout === 'horizontal') {
if (typeof updateActiveStatesHorizontal === 'function') {
console.log("Popstate: Calling updateActiveStatesHorizontal for horizontal layout with a delay.");
setTimeout(() => {
console.log("[Delayed Update from Popstate] Calling updateActiveStatesHorizontal.");
updateActiveStatesHorizontal();
}, 150); // Slightly longer delay for popstate, as full content might be re-initializing
}
}
});
// Check for direct URL access on page load
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
console.log('Checking for deep links after DOM content loaded');
if (window.location.pathname !== '/') {
handleURLNavigation();
}
// Ensure gallery visibility after URL navigation
setTimeout(ensureGalleryVisibility, 1000);
}, 500);
});
document.addEventListener('page-save-requested', function(e) {
const data = e.detail;
if (data && data.pageId && data.elements) {
// Find the gallery/page that corresponds to this pageId
const page = galleries.find(g => g.pageId === data.pageId);
if (page) {
// Store the page elements
page.pageElements = data.elements;
// Save to server
saveGalleries();
}
}
});
document.addEventListener('DOMContentLoaded', function() {
// Preload the Quill editor dependencies when page loads
if (window.RichTextEditor && typeof window.RichTextEditor._ensureEditorDependencies === 'function') {
console.log('Preloading Rich Text Editor dependencies');
window.RichTextEditor._ensureEditorDependencies().then(() => {
console.log('Rich Text Editor dependencies preloaded successfully');
}).catch(error => {
console.warn('Failed to preload Rich Text Editor:', error);
});
} else {
console.warn('Rich Text Editor not available for preloading');
// If not available yet, try again after a delay
setTimeout(() => {
if (window.RichTextEditor && typeof window.RichTextEditor._ensureEditorDependencies === 'function') {
console.log('Retrying Rich Text Editor preload');
window.RichTextEditor._ensureEditorDependencies().catch(e => console.warn(e));
}
}, 2000);
}
});
document.addEventListener('DOMContentLoaded', function() {
const submenuCheck = document.getElementById('createSubmenu');
const submenuTitleField = document.getElementById('submenuTitle');
const submenuTitleGroup = submenuTitleField?.parentElement;
if (submenuCheck && submenuTitleGroup) {
submenuCheck.addEventListener('change', function() {
submenuTitleGroup.style.display = this.checked ? 'block' : 'none';
});
}
});
// Also check immediately if document is already loaded
if (document.readyState !== 'loading') {
setTimeout(function() {
console.log('Document already loaded, checking for deep links');
if (window.location.pathname !== '/') {
handleURLNavigation();
}
}, 300);
}
Add Sidebar Element
Select the type of element to add:
Site Metadata
Recommended length: 50-60 characters
Recommended length: 150-160 characters
Example: G-XXXXXXXXXX or UA-XXXXXXXX-X
Set the primary language of your site content. Chrome will offer to translate if visitor's language is different.
Use this if the site is under development or shouldn't appear in search results
When enabled, the copyright text shows at the bottom of the sidebar.
This text appears in the sidebar footer and inside the copyright overlay.
Upload an image file for your site favicon (recommended: 32x32px or 16x16px, PNG or ICO format)
Import Galleries from JSON or Classic GUID
Paste the full site JSON into the text area below, OR enter the GUID of a classic gallery collection.
OR
Use a GUID to fetch from classic site, or use import_ format to fetch from R2 storage.
If checked, the current menu will be deleted and replaced with the imported items. If unchecked, imported items will be added to the current menu.